fix: define REPO_NAME in hf_upload.sh (ensure_blade_space referenced it)

#9
by BladeSzaSza - opened
formscout/config.py CHANGED
@@ -147,14 +147,35 @@ LLAMA_CPP_PORT_EMBED = 8081
147
  # ─── Judge backend selection ────────────────────────────────────────────────
148
  # "llama_cpp" β€” local llama-server (default for local dev; works perfectly)
149
  # "transformers"β€” in-process Qwen3-VL via transformers, GPU on HF Spaces (ZeroGPU)
150
- # "auto" β€” transformers on a Space (SPACE_ID set), llama_cpp locally
151
  JUDGE_BACKEND = os.environ.get("FORMSCOUT_JUDGE_BACKEND", "auto")
152
  JUDGE_HF_MODEL = os.environ.get("FORMSCOUT_JUDGE_HF_MODEL", "Qwen/Qwen3-VL-8B-Instruct")
153
  ON_HF_SPACE = bool(os.environ.get("SPACE_ID"))
154
 
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  def resolve_judge_backend() -> str:
157
- """Resolve the effective judge backend from JUDGE_BACKEND + environment."""
 
 
 
 
 
 
158
  if JUDGE_BACKEND in ("llama_cpp", "transformers"):
159
  return JUDGE_BACKEND
160
- return "transformers" if ON_HF_SPACE else "llama_cpp"
 
147
  # ─── Judge backend selection ────────────────────────────────────────────────
148
  # "llama_cpp" β€” local llama-server (default for local dev; works perfectly)
149
  # "transformers"β€” in-process Qwen3-VL via transformers, GPU on HF Spaces (ZeroGPU)
150
+ # "auto" β€” transformers ONLY on a GPU/ZeroGPU Space, else llama_cpp
151
  JUDGE_BACKEND = os.environ.get("FORMSCOUT_JUDGE_BACKEND", "auto")
152
  JUDGE_HF_MODEL = os.environ.get("FORMSCOUT_JUDGE_HF_MODEL", "Qwen/Qwen3-VL-8B-Instruct")
153
  ON_HF_SPACE = bool(os.environ.get("SPACE_ID"))
154
 
155
 
156
+ def has_gpu() -> bool:
157
+ """True on a ZeroGPU Space (env flag) or when CUDA is actually present.
158
+
159
+ ZeroGPU exposes no CUDA outside @spaces.GPU, so it is detected via the
160
+ SPACES_ZERO_GPU env flag; ordinary GPU Spaces report via torch.cuda.
161
+ """
162
+ if os.environ.get("SPACES_ZERO_GPU") or os.environ.get("ZERO_GPU"):
163
+ return True
164
+ try:
165
+ import torch
166
+ return bool(torch.cuda.is_available())
167
+ except Exception:
168
+ return False
169
+
170
+
171
  def resolve_judge_backend() -> str:
172
+ """Resolve the effective judge backend from JUDGE_BACKEND + environment.
173
+
174
+ `auto` only engages the heavy in-process transformers model when a GPU is
175
+ actually available β€” a CPU-only Space stays on llama_cpp (which is then
176
+ unreachable, so the Judge falls back to the fast rubric instead of trying to
177
+ run a 17 GB model on CPU).
178
+ """
179
  if JUDGE_BACKEND in ("llama_cpp", "transformers"):
180
  return JUDGE_BACKEND
181
+ return "transformers" if (ON_HF_SPACE and has_gpu()) else "llama_cpp"
formscout/serving/transformers_vlm.py CHANGED
@@ -39,19 +39,27 @@ except Exception: # pragma: no cover
39
  return fn
40
 
41
 
42
- @_gpu
43
- def _generate(model_id: str, prompt: str, pil_images: list, max_tokens: int,
44
- temperature: float) -> str: # pragma: no cover - needs GPU + model
45
- """Load (cached) and run Qwen3-VL; returns the raw decoded string."""
46
- import torch
47
- from transformers import AutoModelForImageTextToText, AutoProcessor
48
-
49
  if "model" not in _CACHE:
 
 
50
  _CACHE["processor"] = AutoProcessor.from_pretrained(model_id)
51
  _CACHE["model"] = AutoModelForImageTextToText.from_pretrained(
52
- model_id, torch_dtype="auto", device_map="auto",
53
  )
 
 
 
 
 
 
 
 
 
54
  processor, model = _CACHE["processor"], _CACHE["model"]
 
55
 
56
  content = [{"type": "image", "image": im} for im in pil_images]
57
  content.append({"type": "text", "text": prompt})
@@ -60,7 +68,7 @@ def _generate(model_id: str, prompt: str, pil_images: list, max_tokens: int,
60
  inputs = processor.apply_chat_template(
61
  messages, tokenize=True, add_generation_prompt=True,
62
  return_tensors="pt", return_dict=True,
63
- ).to(model.device)
64
 
65
  with torch.no_grad():
66
  out = model.generate(
@@ -90,7 +98,8 @@ class TransformersVLMClient:
90
  stop: list[str] | None = None) -> dict:
91
  try:
92
  pil_images = self._decode_images(images)
93
- text = _generate(self.model_id, prompt, pil_images, max_tokens, temperature)
 
94
  return LlamaCppClient._parse_json_reply(text)
95
  except Exception as e: # pragma: no cover - needs GPU + model
96
  logger.warning("transformers VLM failed (%s) β€” falling back to rubric", e)
 
39
  return fn
40
 
41
 
42
+ def _ensure_loaded(model_id: str): # pragma: no cover - downloads ~16 GB
43
+ """Load processor + model to CPU once (cached). Kept OUT of the GPU window so
44
+ the 17 GB download/load does not eat ZeroGPU time."""
 
 
 
 
45
  if "model" not in _CACHE:
46
+ import torch
47
+ from transformers import AutoModelForImageTextToText, AutoProcessor
48
  _CACHE["processor"] = AutoProcessor.from_pretrained(model_id)
49
  _CACHE["model"] = AutoModelForImageTextToText.from_pretrained(
50
+ model_id, torch_dtype=torch.bfloat16,
51
  )
52
+ return _CACHE["processor"], _CACHE["model"]
53
+
54
+
55
+ @_gpu
56
+ def _generate(prompt: str, pil_images: list, max_tokens: int,
57
+ temperature: float) -> str: # pragma: no cover - needs GPU + model
58
+ """Move the cached model to CUDA and run Qwen3-VL (ZeroGPU window)."""
59
+ import torch
60
+
61
  processor, model = _CACHE["processor"], _CACHE["model"]
62
+ model.to("cuda")
63
 
64
  content = [{"type": "image", "image": im} for im in pil_images]
65
  content.append({"type": "text", "text": prompt})
 
68
  inputs = processor.apply_chat_template(
69
  messages, tokenize=True, add_generation_prompt=True,
70
  return_tensors="pt", return_dict=True,
71
+ ).to("cuda")
72
 
73
  with torch.no_grad():
74
  out = model.generate(
 
98
  stop: list[str] | None = None) -> dict:
99
  try:
100
  pil_images = self._decode_images(images)
101
+ _ensure_loaded(self.model_id) # CPU load (no GPU time)
102
+ text = _generate(prompt, pil_images, max_tokens, temperature)
103
  return LlamaCppClient._parse_json_reply(text)
104
  except Exception as e: # pragma: no cover - needs GPU + model
105
  logger.warning("transformers VLM failed (%s) β€” falling back to rubric", e)
scripts/hf_upload.sh CHANGED
@@ -21,8 +21,10 @@ set -euo pipefail
21
 
22
  cd "$(dirname "$0")/.."
23
 
24
- MODEL_REPO="silas-therapy/small-functional-movement-screening"
25
- SPACE_REPO="spaces/silas-therapy/small-functional-movement-screening"
 
 
26
  MSG="${1:-$(git log -1 --pretty=%s)}"
27
  LARGE_THRESHOLD="${FORMSCOUT_HF_LARGE_THRESHOLD:-500}"
28
 
@@ -76,22 +78,41 @@ if (( N_FILES == 0 )); then
76
  exit 1
77
  fi
78
 
 
 
 
79
  upload_repo() {
80
  local repo="$1"
 
81
  if (( N_FILES > LARGE_THRESHOLD )); then
82
  echo "── $repo: $N_FILES files > $LARGE_THRESHOLD, using upload-large-folder"
83
  echo " (resumable; commits directly to main β€” no PR, no custom message)"
84
  hf upload-large-folder "$repo" . "${EXCLUDES[@]}"
 
 
 
85
  else
86
- echo "── uploading to: $repo"
87
- hf upload "$repo" . . \
88
- "${EXCLUDES[@]}" \
89
- --create-pr \
90
- --commit-message="$MSG"
91
  fi
92
  }
93
 
94
- upload_repo "$MODEL_REPO"
95
- upload_repo "$SPACE_REPO"
 
 
 
 
 
 
 
 
 
 
96
 
 
 
 
 
 
97
  echo "βœ“ done"
 
21
 
22
  cd "$(dirname "$0")/.."
23
 
24
+ REPO_NAME="small-functional-movement-screening"
25
+ MODEL_REPO="silas-therapy/$REPO_NAME"
26
+ SPACE_REPO="spaces/silas-therapy/$REPO_NAME"
27
+ SPACE_BLADESZASZA_REPO="spaces/bladeszasza/$REPO_NAME"
28
  MSG="${1:-$(git log -1 --pretty=%s)}"
29
  LARGE_THRESHOLD="${FORMSCOUT_HF_LARGE_THRESHOLD:-500}"
30
 
 
78
  exit 1
79
  fi
80
 
81
+ # upload_repo <repo> [pr|direct]
82
+ # pr β€” open a PR (shared org repos; review before merge)
83
+ # direct β€” commit straight to main (repos you own; deploys immediately)
84
  upload_repo() {
85
  local repo="$1"
86
+ local mode="${2:-pr}"
87
  if (( N_FILES > LARGE_THRESHOLD )); then
88
  echo "── $repo: $N_FILES files > $LARGE_THRESHOLD, using upload-large-folder"
89
  echo " (resumable; commits directly to main β€” no PR, no custom message)"
90
  hf upload-large-folder "$repo" . "${EXCLUDES[@]}"
91
+ elif [[ "$mode" == "direct" ]]; then
92
+ echo "── uploading (direct β†’ main) to: $repo"
93
+ hf upload "$repo" . . "${EXCLUDES[@]}" --commit-message="$MSG"
94
  else
95
+ echo "── uploading (PR) to: $repo"
96
+ hf upload "$repo" . . "${EXCLUDES[@]}" --create-pr --commit-message="$MSG"
 
 
 
97
  fi
98
  }
99
 
100
+ # Ensure the personal ZeroGPU Space exists. Tries zero-a10g (needs Pro/ZeroGPU);
101
+ # falls back to cpu-basic so the upload still has a target (set ZeroGPU in
102
+ # Settings afterward). Idempotent via --exist-ok.
103
+ ensure_blade_space() {
104
+ local id="bladeszasza/$REPO_NAME"
105
+ if hf repos create "$id" --type space --space-sdk gradio --flavor zero-a10g --exist-ok 2>/dev/null; then
106
+ echo "── Space ready (ZeroGPU / zero-a10g): $id"
107
+ else
108
+ echo "── zero-a10g unavailable (Pro/ZeroGPU quota?) β€” creating cpu-basic; switch to ZeroGPU in Settings"
109
+ hf repos create "$id" --type space --space-sdk gradio --exist-ok || true
110
+ fi
111
+ }
112
 
113
+ # Shared org repos β†’ PRs; personal ZeroGPU Space β†’ created + direct deploy.
114
+ upload_repo "$MODEL_REPO" pr
115
+ upload_repo "$SPACE_REPO" pr
116
+ ensure_blade_space
117
+ upload_repo "$SPACE_BLADESZASZA_REPO" direct
118
  echo "βœ“ done"
tests/test_judge_backend.py CHANGED
@@ -21,9 +21,19 @@ def test_resolve_backend_default_local(monkeypatch):
21
  assert cfg.resolve_judge_backend() == "llama_cpp"
22
 
23
 
24
- def test_resolve_backend_auto_on_space(monkeypatch):
25
- cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto", SPACE_ID="me/space")
 
26
  assert cfg.resolve_judge_backend() == "transformers"
 
 
 
 
 
 
 
 
 
27
 
28
 
29
  def test_resolve_backend_explicit(monkeypatch):
 
21
  assert cfg.resolve_judge_backend() == "llama_cpp"
22
 
23
 
24
+ def test_resolve_backend_auto_on_zero_gpu_space(monkeypatch):
25
+ cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto",
26
+ SPACE_ID="me/space", SPACES_ZERO_GPU="true")
27
  assert cfg.resolve_judge_backend() == "transformers"
28
+ importlib.reload(config)
29
+
30
+
31
+ def test_resolve_backend_auto_on_cpu_space_stays_llama(monkeypatch):
32
+ # A CPU-only Space must NOT load the 17 GB transformers model.
33
+ cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto",
34
+ SPACE_ID="me/space", SPACES_ZERO_GPU=None, ZERO_GPU=None)
35
+ assert cfg.resolve_judge_backend() == "llama_cpp"
36
+ importlib.reload(config)
37
 
38
 
39
  def test_resolve_backend_explicit(monkeypatch):