Spaces:
Running on Zero
Running on Zero
App back to ZeroGPU (merged in-Space); Modal offline-only
Browse files- README.md +7 -6
- core/infer.py +87 -29
- requirements.txt +9 -6
README.md
CHANGED
|
@@ -46,9 +46,10 @@ schema from a minimal ask.
|
|
| 46 |
- **Student (in the app):** [`Qwen3-4B-Instruct-2507`](https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507) + a published **Case Forge LoRA** adapter.
|
| 47 |
- **Teacher (offline only):** a large dense model on Modal that generated the
|
| 48 |
synthetic training corpus. It never ships in the app and is not counted below.
|
| 49 |
-
- **Runtime:** the student
|
| 50 |
-
|
| 51 |
-
|
|
|
|
| 52 |
- **Quality:** an Opus-4.8 content audit found the first model fabricated sources and
|
| 53 |
made numeric errors; the corpus was regenerated with a numeric-auditor pass and the
|
| 54 |
model retrained (**v3**) — fabricated sources and severe math errors went to **0/6**
|
|
@@ -84,9 +85,9 @@ when generating the corpus and when validating in the app.
|
|
| 84 |
|
| 85 |
**Backyard AI** (a real instructor authoring cases he'll teach) · **Well-Tuned**
|
| 86 |
(published fine-tune) · **Tiny Titan** (≤4B) · **Best Agent** (multi-stage
|
| 87 |
-
teacher→audit→student pipeline) · **Modal** (corpus generation, fine-tune
|
| 88 |
-
|
| 89 |
-
|
| 90 |
|
| 91 |
## Run locally
|
| 92 |
|
|
|
|
| 46 |
- **Student (in the app):** [`Qwen3-4B-Instruct-2507`](https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507) + a published **Case Forge LoRA** adapter.
|
| 47 |
- **Teacher (offline only):** a large dense model on Modal that generated the
|
| 48 |
synthetic training corpus. It never ships in the app and is not counted below.
|
| 49 |
+
- **Runtime:** the student runs **in-Space on ZeroGPU** (free) — base Qwen3-4B + the
|
| 50 |
+
published LoRA, merged at the first GPU call, generating via transformers. **Modal**
|
| 51 |
+
is used only for the **offline** pipeline (corpus generation, fine-tune, LoRA merge),
|
| 52 |
+
which is pay-per-use; keeping a GPU warm for serving was not cost-viable.
|
| 53 |
- **Quality:** an Opus-4.8 content audit found the first model fabricated sources and
|
| 54 |
made numeric errors; the corpus was regenerated with a numeric-auditor pass and the
|
| 55 |
model retrained (**v3**) — fabricated sources and severe math errors went to **0/6**
|
|
|
|
| 85 |
|
| 86 |
**Backyard AI** (a real instructor authoring cases he'll teach) · **Well-Tuned**
|
| 87 |
(published fine-tune) · **Tiny Titan** (≤4B) · **Best Agent** (multi-stage
|
| 88 |
+
teacher→audit→student pipeline) · **Modal** (corpus generation, fine-tune and LoRA
|
| 89 |
+
merge run on Modal credits) · **Off-Grid** (app serves its own weights in-Space on
|
| 90 |
+
ZeroGPU — no third-party model API).
|
| 91 |
|
| 92 |
## Run locally
|
| 93 |
|
core/infer.py
CHANGED
|
@@ -1,15 +1,17 @@
|
|
| 1 |
"""Inference for the fine-tuned student — short request → full case+note JSON.
|
| 2 |
|
| 3 |
-
Runtime = **
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
back to a real sample
|
|
|
|
| 8 |
|
| 9 |
Config (env):
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
@@ -19,6 +21,8 @@ import os
|
|
| 19 |
import sys
|
| 20 |
from pathlib import Path
|
| 21 |
|
|
|
|
|
|
|
| 22 |
_ROOT = Path(__file__).resolve().parent.parent # case-forge/
|
| 23 |
_MONOREPO = _ROOT.parent # build-small-hackathon/
|
| 24 |
for _p in (str(_ROOT), str(_MONOREPO)):
|
|
@@ -26,25 +30,78 @@ for _p in (str(_ROOT), str(_MONOREPO)):
|
|
| 26 |
sys.path.insert(0, _p)
|
| 27 |
|
| 28 |
from data.schema import validate_case # noqa: E402
|
| 29 |
-
from pipeline.prompts import Seed
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
| 33 |
FORCE_DEMO = os.environ.get("CASE_FORGE_DEMO", "").strip() in ("1", "true", "yes")
|
| 34 |
|
| 35 |
-
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
-
def
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
# --- demo fallback -------------------------------------------------------
|
|
@@ -53,8 +110,7 @@ _DEMO_BANK: dict[str, dict] | None = None
|
|
| 53 |
|
| 54 |
|
| 55 |
def _demo_bank() -> dict[str, dict]:
|
| 56 |
-
"""One valid sample case per language from the local corpus —
|
| 57 |
-
UI (render, badges, export) work without Modal (offline/screenshots)."""
|
| 58 |
global _DEMO_BANK
|
| 59 |
if _DEMO_BANK is not None:
|
| 60 |
return _DEMO_BANK
|
|
@@ -98,7 +154,7 @@ def _demo_result(seed: Seed) -> dict:
|
|
| 98 |
|
| 99 |
def generate(domain: str, topic: str, level: str = "MBA",
|
| 100 |
language: str = "pt", theory: str = "") -> dict:
|
| 101 |
-
"""Forge one case+note from a short request,
|
| 102 |
|
| 103 |
Returns {obj, valid, errors, warnings, raw, demo}.
|
| 104 |
"""
|
|
@@ -110,18 +166,20 @@ def generate(domain: str, topic: str, level: str = "MBA",
|
|
| 110 |
theory=[t.strip() for t in (theory or "").split(",") if t.strip()],
|
| 111 |
)
|
| 112 |
|
| 113 |
-
if FORCE_DEMO or not
|
| 114 |
return _demo_result(seed)
|
| 115 |
|
| 116 |
try:
|
| 117 |
-
|
| 118 |
-
except Exception as exc:
|
| 119 |
out = _demo_result(seed)
|
| 120 |
-
out["errors"] = [f"falha na geração
|
| 121 |
return out
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
__all__ = ["generate", "Seed"]
|
|
|
|
| 1 |
"""Inference for the fine-tuned student — short request → full case+note JSON.
|
| 2 |
|
| 3 |
+
Runtime = **ZeroGPU**, in-Space (free). On the first GPU call we load base
|
| 4 |
+
Qwen3-4B + the published LoRA and `merge_and_unload()` it (folding the adapter into
|
| 5 |
+
the weights removes PEFT overhead → faster decode), then generate via transformers
|
| 6 |
+
under `@spaces.GPU`. `max_new_tokens` is capped so a full case fits ZeroGPU's ~120s
|
| 7 |
+
window. Locally (no `spaces`/CUDA) it falls back to a real sample so the UI works
|
| 8 |
+
offline. Heavy/offline work (corpus gen, training, merge) runs on Modal, not here.
|
| 9 |
|
| 10 |
Config (env):
|
| 11 |
+
CASE_FORGE_BASE base model id
|
| 12 |
+
CASE_FORGE_ADAPTER HF repo id of the published LoRA
|
| 13 |
+
CASE_FORGE_MAX_TOKENS generation cap (default 2800 — fits the ZeroGPU window)
|
| 14 |
+
CASE_FORGE_DEMO=1 force the demo sample (no model load)
|
| 15 |
"""
|
| 16 |
|
| 17 |
from __future__ import annotations
|
|
|
|
| 21 |
import sys
|
| 22 |
from pathlib import Path
|
| 23 |
|
| 24 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 25 |
+
|
| 26 |
_ROOT = Path(__file__).resolve().parent.parent # case-forge/
|
| 27 |
_MONOREPO = _ROOT.parent # build-small-hackathon/
|
| 28 |
for _p in (str(_ROOT), str(_MONOREPO)):
|
|
|
|
| 30 |
sys.path.insert(0, _p)
|
| 31 |
|
| 32 |
from data.schema import validate_case # noqa: E402
|
| 33 |
+
from pipeline.prompts import Seed, build_minimal_prompt # noqa: E402
|
| 34 |
+
from shared import gpu # noqa: E402
|
| 35 |
|
| 36 |
+
BASE_MODEL = os.environ.get("CASE_FORGE_BASE", "Qwen/Qwen3-4B-Instruct-2507")
|
| 37 |
+
ADAPTER_REPO = os.environ.get(
|
| 38 |
+
"CASE_FORGE_ADAPTER", "build-small-hackathon/case-forge-qwen3-4b").strip()
|
| 39 |
+
MAX_NEW_TOKENS = int(os.environ.get("CASE_FORGE_MAX_TOKENS", "2800"))
|
| 40 |
FORCE_DEMO = os.environ.get("CASE_FORGE_DEMO", "").strip() in ("1", "true", "yes")
|
| 41 |
|
| 42 |
+
_MODEL = None
|
| 43 |
+
_TOK = None
|
| 44 |
|
| 45 |
|
| 46 |
+
def _has_cuda() -> bool:
|
| 47 |
+
try:
|
| 48 |
+
import torch
|
| 49 |
+
return torch.cuda.is_available()
|
| 50 |
+
except Exception:
|
| 51 |
+
return False
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _ensure_model() -> None:
|
| 55 |
+
"""Lazy-load base + LoRA and merge — runs inside the GPU-allocated context."""
|
| 56 |
+
global _MODEL, _TOK
|
| 57 |
+
if _MODEL is not None:
|
| 58 |
+
return
|
| 59 |
+
import torch
|
| 60 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 61 |
+
|
| 62 |
+
tok = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
|
| 63 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 64 |
+
BASE_MODEL, torch_dtype=torch.bfloat16, device_map="cuda",
|
| 65 |
+
trust_remote_code=True,
|
| 66 |
+
)
|
| 67 |
+
if ADAPTER_REPO:
|
| 68 |
+
from peft import PeftModel
|
| 69 |
+
model = PeftModel.from_pretrained(model, ADAPTER_REPO)
|
| 70 |
+
model = model.merge_and_unload() # fold LoRA into base → faster generation
|
| 71 |
+
model.eval()
|
| 72 |
+
_MODEL, _TOK = model, tok
|
| 73 |
|
| 74 |
|
| 75 |
+
@gpu.gpu(duration=120)
|
| 76 |
+
def _generate_raw(messages: list[dict]) -> str:
|
| 77 |
+
"""Run the model on ZeroGPU and return the raw decoded completion."""
|
| 78 |
+
import torch
|
| 79 |
+
|
| 80 |
+
_ensure_model()
|
| 81 |
+
try:
|
| 82 |
+
text = _TOK.apply_chat_template(
|
| 83 |
+
messages, tokenize=False, add_generation_prompt=True,
|
| 84 |
+
enable_thinking=False,
|
| 85 |
+
)
|
| 86 |
+
except TypeError:
|
| 87 |
+
text = _TOK.apply_chat_template(
|
| 88 |
+
messages, tokenize=False, add_generation_prompt=True,
|
| 89 |
+
)
|
| 90 |
+
inputs = _TOK(text, return_tensors="pt").to(_MODEL.device)
|
| 91 |
+
with torch.no_grad():
|
| 92 |
+
out = _MODEL.generate(
|
| 93 |
+
**inputs, max_new_tokens=MAX_NEW_TOKENS,
|
| 94 |
+
do_sample=True, temperature=0.7, top_p=0.95,
|
| 95 |
+
pad_token_id=_TOK.pad_token_id or _TOK.eos_token_id,
|
| 96 |
+
)
|
| 97 |
+
return _TOK.decode(out[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _parse(raw: str) -> dict | None:
|
| 101 |
+
try:
|
| 102 |
+
return json.loads(raw[raw.find("{"): raw.rfind("}") + 1])
|
| 103 |
+
except Exception:
|
| 104 |
+
return None
|
| 105 |
|
| 106 |
|
| 107 |
# --- demo fallback -------------------------------------------------------
|
|
|
|
| 110 |
|
| 111 |
|
| 112 |
def _demo_bank() -> dict[str, dict]:
|
| 113 |
+
"""One valid sample case per language from the local corpus — UI works offline."""
|
|
|
|
| 114 |
global _DEMO_BANK
|
| 115 |
if _DEMO_BANK is not None:
|
| 116 |
return _DEMO_BANK
|
|
|
|
| 154 |
|
| 155 |
def generate(domain: str, topic: str, level: str = "MBA",
|
| 156 |
language: str = "pt", theory: str = "") -> dict:
|
| 157 |
+
"""Forge one case+note from a short request, on ZeroGPU.
|
| 158 |
|
| 159 |
Returns {obj, valid, errors, warnings, raw, demo}.
|
| 160 |
"""
|
|
|
|
| 166 |
theory=[t.strip() for t in (theory or "").split(",") if t.strip()],
|
| 167 |
)
|
| 168 |
|
| 169 |
+
if FORCE_DEMO or not (gpu._HAS_SPACES or _has_cuda()):
|
| 170 |
return _demo_result(seed)
|
| 171 |
|
| 172 |
try:
|
| 173 |
+
raw = _generate_raw(build_minimal_prompt(seed))
|
| 174 |
+
except Exception as exc:
|
| 175 |
out = _demo_result(seed)
|
| 176 |
+
out["errors"] = [f"falha na geração: {exc}"] + out["errors"]
|
| 177 |
return out
|
| 178 |
|
| 179 |
+
obj = _parse(raw)
|
| 180 |
+
ok, errs, warns = validate_case(obj) if obj else (False, ["parse falhou"], [])
|
| 181 |
+
return {"obj": obj, "valid": ok, "errors": errs, "warnings": warns,
|
| 182 |
+
"raw": raw, "demo": False}
|
| 183 |
|
| 184 |
|
| 185 |
__all__ = ["generate", "Seed"]
|
requirements.txt
CHANGED
|
@@ -1,10 +1,13 @@
|
|
| 1 |
-
# Case Forge — HF Space runtime (Gradio app).
|
| 2 |
#
|
| 3 |
-
#
|
| 4 |
-
#
|
| 5 |
-
#
|
| 6 |
-
# and calls Modal. Set MODAL_TOKEN_ID / MODAL_TOKEN_SECRET as Space secrets.
|
| 7 |
|
| 8 |
gradio>=6
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
jsonschema>=4 # full structural validation of the output contract
|
|
|
|
| 1 |
+
# Case Forge — HF Space runtime (Gradio app on ZeroGPU).
|
| 2 |
#
|
| 3 |
+
# App inference runs in-Space on ZeroGPU (free): base Qwen3-4B + the published LoRA,
|
| 4 |
+
# merged at first GPU call, generating via transformers under @spaces.GPU.
|
| 5 |
+
# Heavy/offline work (corpus generation, training, LoRA merge) runs on Modal — NOT here.
|
|
|
|
| 6 |
|
| 7 |
gradio>=6
|
| 8 |
+
spaces # ZeroGPU (@spaces.GPU) — no-op locally
|
| 9 |
+
transformers>=4.49
|
| 10 |
+
peft>=0.13 # load + merge_and_unload the adapter
|
| 11 |
+
accelerate>=1
|
| 12 |
+
torch
|
| 13 |
jsonschema>=4 # full structural validation of the output contract
|