nextmarte commited on
Commit
59a4de2
·
verified ·
1 Parent(s): 53555f7

App back to ZeroGPU (merged in-Space); Modal offline-only

Browse files
Files changed (3) hide show
  1. README.md +7 -6
  2. core/infer.py +87 -29
  3. 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 is **served on Modal** (a deployed warm vLLM class,
50
- `case-forge-serve`); the Space itself runs on CPU and calls it. Warm latency ≈ a
51
- few seconds per case; the first call after idle pays a cold start (~2 min).
 
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, **and**
88
- serving all run on Modal). (Off-Grid is ambiguous here own weights, but on rented
89
- GPUso not claimed.)
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
+ ZeroGPUno 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 = **Modal** (no ZeroGPU for this project). The Gradio app calls a deployed
4
- warm Modal class (`case-forge-serve` / `CaseForge`, see `pipeline/serve_modal.py`)
5
- that holds base Qwen3-4B + the `qwen3-4b-v3` LoRA. Modal creds come from the Space
6
- secrets (MODAL_TOKEN_ID / MODAL_TOKEN_SECRET). With no creds / on failure it falls
7
- back to a real sample case so the UI is fully testable offline.
 
8
 
9
  Config (env):
10
- CASE_FORGE_MODAL_APP deployed Modal app name (default case-forge-serve)
11
- CASE_FORGE_MODAL_CLS class name (default CaseForge)
12
- CASE_FORGE_DEMO=1 force the demo sample (no Modal call)
 
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 # noqa: E402
 
30
 
31
- MODAL_APP = os.environ.get("CASE_FORGE_MODAL_APP", "case-forge-serve")
32
- MODAL_CLS = os.environ.get("CASE_FORGE_MODAL_CLS", "CaseForge")
 
 
33
  FORCE_DEMO = os.environ.get("CASE_FORGE_DEMO", "").strip() in ("1", "true", "yes")
34
 
35
- _CLS = None # cached Modal class handle
 
36
 
37
 
38
- def _modal_ready() -> bool:
39
- return bool(os.environ.get("MODAL_TOKEN_ID") and os.environ.get("MODAL_TOKEN_SECRET"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
 
42
- def _cls():
43
- global _CLS
44
- if _CLS is None:
45
- import modal
46
- _CLS = modal.Cls.from_name(MODAL_APP, MODAL_CLS)
47
- return _CLS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 — lets the whole
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, via the Modal server.
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 _modal_ready():
114
  return _demo_result(seed)
115
 
116
  try:
117
- res = _cls()().generate.remote(seed.__dict__)
118
- except Exception as exc: # Modal unreachable / cold-start failure → don't crash UI
119
  out = _demo_result(seed)
120
- out["errors"] = [f"falha na geração (Modal): {exc}"] + out["errors"]
121
  return out
122
 
123
- res["demo"] = False
124
- return res
 
 
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
- # Inference runs on MODAL (no ZeroGPU for this project): the app calls a deployed
4
- # warm Modal class (case-forge-serve / CaseForge, see pipeline/serve_modal.py),
5
- # so the Space itself needs NO GPU libs (torch/transformers/peft)it just renders
6
- # and calls Modal. Set MODAL_TOKEN_ID / MODAL_TOKEN_SECRET as Space secrets.
7
 
8
  gradio>=6
9
- modal # calls the deployed Modal inference server
 
 
 
 
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