File size: 16,848 Bytes
3b09ba9 57bc041 3b09ba9 57bc041 3b09ba9 57bc041 3b09ba9 0c18fce 3b09ba9 | 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 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 | """
Hugging Face Spaces (ZeroGPU) front end for the Paper->Code grounding
pipeline -- runs Qwen3-VL-32B-Instruct-FP8 (the same model validated by
hand throughout this project) instead of Gemini, so this deploys the
project's OWN model for free, public use.
Real constraints, on purpose, not hidden:
- ZeroGPU is Gradio-only, so this is a Gradio Blocks app, not the
FastAPI + custom HTML used by scripts/webapp/server.py.
- No vLLM here -- ZeroGPU allocates real GPU access only for the
duration of an `@spaces.GPU`-decorated function call, which doesn't
fit a long-running server process. This uses plain
transformers.generate(), the same approach scripts/local_agent/
run_local_agent.py already validated, just collapsed to one
single-turn call (see run_single_turn_transformers below) instead of
that script's multi-turn tool-calling loop.
- Each visitor's own HF account has a small DAILY GPU-second quota
(2 min unauthenticated, 5 min free account) -- a real single-turn
call here is likely 70-130s, so this is genuinely "a couple of tries
per person per day," not unlimited use.
"""
from __future__ import annotations
import base64
import io
import json
import subprocess
import sys
import time
from pathlib import Path
import gradio as gr
import spaces
import torch
from PIL import Image
from transformers import AutoModelForImageTextToText, AutoProcessor, BitsAndBytesConfig
# Locally (inside a checkout of the full PaperCodeDiagramApp repo) this
# file lives at scripts/spaces_app/app.py, three levels under the repo
# root. Uploaded to its own standalone Space repo, app.py sits at the
# Space's own root instead, alongside its own copies of the same shared
# files (RULES.md, schema/, scripts/... -- see the upload staging step).
# Detect which layout is actually on disk rather than requiring an env
# var to be configured correctly on the Space.
import os # noqa: E402
_repo_root_override = os.environ.get("PCD_REPO_ROOT")
if _repo_root_override:
REPO_ROOT = Path(_repo_root_override)
elif (Path(__file__).resolve().parent / "RULES.md").is_file():
REPO_ROOT = Path(__file__).resolve().parent # standalone Space layout
else:
REPO_ROOT = Path(__file__).resolve().parents[2] # local dev, full repo checkout
sys.path.insert(0, str(REPO_ROOT / "scripts" / "local_agent"))
sys.path.insert(0, str(REPO_ROOT / "scripts" / "dataset"))
sys.path.insert(0, str(REPO_ROOT / "scripts" / "webapp"))
from run_local_agent import setup_case # noqa: E402
from run_vllm_agent import ( # noqa: E402
RULES_PATH, SCHEMA_PATH, VERIFY_SCRIPT, _dump_narrative,
build_system_text, collect_repo_manifest, collect_repo_source_dump,
)
from tools import CaseWorkspace, ToolError # noqa: E402
from validation import extract_github_repo, looks_like_a_paper, normalize_paper_url, repo_exists # noqa: E402
MODEL_ID = "Qwen/Qwen3-VL-32B-Instruct"
IMAGE_SIZE = {"shortest_edge": 16384, "longest_edge": 400000}
MAX_PAGES = 10
TOKEN_BUDGETS = [4096, 8192] # kept modest: every extra token here is GPU time out of the visitor's own daily quota
MAX_FIX_ATTEMPTS = 1
CASES_DIR = REPO_ROOT / "cases"
UPLOADS_DIR = Path(__file__).resolve().parent / "_uploads"
print(f"loading {MODEL_ID} ...", flush=True)
processor = AutoProcessor.from_pretrained(MODEL_ID, size=IMAGE_SIZE)
# 8-bit bitsandbytes instead of the FP8 checkpoint: a bf16 32B model alone
# needs ~64GB (too much for ZeroGPU's default 48GB budget), and the FP8
# checkpoint's quantized kernels are downloaded from the Hub at runtime by
# the `kernels` package, which repeatedly failed to find a build matching
# this container's torch/CUDA. bitsandbytes' kernels ship inside the pip
# package itself -- no runtime download -- and int8 needs ~32GB, same
# budget the FP8 checkpoint was chosen for.
model = AutoModelForImageTextToText.from_pretrained(
MODEL_ID,
quantization_config=BitsAndBytesConfig(load_in_8bit=True),
device_map="cuda",
)
model.eval()
print("model loaded", flush=True)
def _b64_to_pil(data: str) -> Image.Image:
return Image.open(io.BytesIO(base64.b64decode(data))).convert("RGB")
def _build_messages(ws: CaseWorkspace, system_text: str, schema: dict) -> list[dict]:
content: list[dict] = [{"type": "text", "text": (
"SINGLE-TURN TASK: you get exactly this one message and must produce "
"exactly one response -- the complete, final narrative.json object as "
"raw JSON, nothing else (no prose, no markdown fences, no tool calls -- "
"there are no tools in this mode). Every PDF page you need and the "
"repo's full source are already attached below; you will not get a "
"chance to ask for anything else or to revise after this. Skip figures "
"entirely: omit `case.overview.figure` and every node's `figure_refs`. "
"Ground every code_ref in the attached source using its real file path "
"and line numbers (the numbers prefixing each line below). A full "
"manifest of every file's REAL path and REAL line count is included "
"below the page images -- check every citation's file path and line "
"range against that manifest before writing it; never cite a path "
"that isn't in the manifest, and never cite a line past a file's "
"listed length. Make sure start_line <= end_line for every citation. "
"Respond with ONLY the raw JSON object, starting with `{` and ending "
f"with `}}`. The schema your JSON must match:\n{json.dumps(schema)}"
)}]
for page in range(1, MAX_PAGES + 1):
try:
blocks = ws.read_pdf_page(page)
except ToolError:
break
content.append({"type": "text", "text": f"--- paper.pdf page {page} ---"})
for b in blocks:
if b["type"] == "image":
content.append({"type": "image", "image": _b64_to_pil(b["source"]["data"])})
manifest = collect_repo_manifest(ws)
content.append({"type": "text", "text": f"--- repo/ file manifest (real path : real line count) ---\n{manifest}"})
repo_dump = collect_repo_source_dump(ws)
content.append({"type": "text", "text": f"--- full repo source (repo/) ---\n{repo_dump}"})
return [
{"role": "system", "content": system_text},
{"role": "user", "content": content},
]
def _collect_images(messages: list[dict]) -> list[Image.Image]:
imgs = []
for m in messages:
content = m.get("content")
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "image":
imgs.append(item["image"])
return imgs
def _extract_json(raw: str) -> dict | None:
"""The model was asked for raw JSON only, but strip a markdown fence
if it added one anyway -- cheap and doesn't mask a real parse failure
for anything else."""
text = raw.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text
if text.endswith("```"):
text = text.rsplit("```", 1)[0]
try:
return json.loads(text)
except json.JSONDecodeError:
return None
@spaces.GPU(duration=90)
def _generate(messages: list[dict], max_new_tokens: int) -> tuple[str, str]:
"""The only part that actually needs the GPU. Returns (generated_text,
finish_reason) -- finish_reason is "length" if max_new_tokens was hit
(transformers doesn't expose a stop-reason the way an API does, so
this is inferred: did decoding stop because of an EOS token, or
because we ran out of budget)."""
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
images = _collect_images(messages)
inputs = processor(text=[text], images=[images] if images else None, return_tensors="pt").to(model.device)
with torch.no_grad():
out_ids = model.generate(
**inputs, max_new_tokens=max_new_tokens, do_sample=True,
temperature=0.4, top_p=0.9, repetition_penalty=1.1,
)
new_ids = out_ids[0][inputs["input_ids"].shape[1]:]
finish_reason = "length" if len(new_ids) >= max_new_tokens else "stop"
generated = processor.tokenizer.decode(new_ids, skip_special_tokens=True)
return generated, finish_reason
def _generate_narrative_once(messages: list[dict], log) -> tuple[dict | None, float]:
total_latency = 0.0
for attempt, budget in enumerate(TOKEN_BUDGETS, start=1):
t0 = time.perf_counter()
raw, finish_reason = _generate(messages, budget)
latency = time.perf_counter() - t0
total_latency += latency
log(f"--- generation attempt {attempt}/{len(TOKEN_BUDGETS)} "
f"(max_new_tokens={budget}, {latency:.1f}s, finish_reason={finish_reason}) ---")
if finish_reason == "length":
log(f"truncated at {budget} tokens" + (" -- retrying with more room" if attempt < len(TOKEN_BUDGETS) else " -- out of retries"))
continue
narrative = _extract_json(raw)
if narrative is not None:
return narrative, total_latency
log("FAILED: response was not valid JSON" + (" -- retrying" if attempt < len(TOKEN_BUDGETS) else " -- out of retries"))
return None, total_latency
def ground_paper(paper_url: str, progress=gr.Progress()):
log_lines: list[str] = []
def log(msg: str) -> None:
log_lines.append(msg)
if not paper_url or not paper_url.strip():
return "Enter a paper URL first.", None, gr.update()
progress(0.05, desc="downloading + validating the paper")
url = normalize_paper_url(paper_url.strip())
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
staged_pdf = UPLOADS_DIR / f"{abs(hash(url))}.pdf"
try:
import urllib.request
urllib.request.urlretrieve(url, staged_pdf)
except Exception as e: # noqa: BLE001
return f"Couldn't download {url}: {e}", None, gr.update()
ok, reason = looks_like_a_paper(staged_pdf)
if not ok:
staged_pdf.unlink(missing_ok=True)
return f"(1) doesn't look like a paper: {reason}", None, gr.update()
repo_url = extract_github_repo(staged_pdf)
if not repo_url:
staged_pdf.unlink(missing_ok=True)
return "(2) no GitHub repository link found anywhere in this paper's text", None, gr.update()
progress(0.15, desc=f"found repo: {repo_url} -- cloning")
import re
slug = re.sub(r"[^a-z0-9]+", "-", staged_pdf.stem.lower()).strip("-") or "case"
case_dir, slug = setup_case(str(staged_pdf), repo_url, CASES_DIR, slug)
if not repo_exists(repo_url):
return f"Repo not reachable: {repo_url}", None, gr.update()
progress(0.3, desc="reading paper + repo, building the prompt")
ws = CaseWorkspace(case_dir)
system_text = build_system_text(None, None)
schema = json.loads(SCHEMA_PATH.read_text())
messages = _build_messages(ws, system_text, schema)
progress(0.4, desc="calling the model (this is the slow part)")
narrative, latency = _generate_narrative_once(messages, log)
if narrative is None:
return "The model didn't produce a valid response:\n\n" + "\n".join(log_lines), None, gr.update()
fix_attempts = 0
while True:
progress(0.8, desc="verifying citations against the real code")
resolved = ws.resolve_code_snippets(narrative)
narrative_path = case_dir / "narrative.json"
narrative_path.write_text(json.dumps(resolved, indent=2, ensure_ascii=False) + "\n")
verify = subprocess.run(
[sys.executable, str(VERIFY_SCRIPT), str(narrative_path)],
capture_output=True, text=True,
)
log(f"=== verify_case.py (returncode={verify.returncode}) ===\n{verify.stdout}\n{verify.stderr}")
if verify.returncode == 0:
_dump_narrative(resolved)
progress(1.0, desc="done")
return _summary_markdown(resolved, True, latency, log_lines), resolved, gr.update(choices=_list_case_choices(), value=slug)
if fix_attempts >= MAX_FIX_ATTEMPTS:
_dump_narrative(resolved)
return _summary_markdown(resolved, False, latency, log_lines), resolved, gr.update(choices=_list_case_choices(), value=slug)
fix_attempts += 1
progress(0.6, desc=f"fixing a citation issue (attempt {fix_attempts}/{MAX_FIX_ATTEMPTS})")
messages.append({"role": "assistant", "content": json.dumps(narrative)})
messages.append({"role": "user", "content": (
f"scripts/verify_case.py rejected that submission:\n\n{verify.stdout}\n{verify.stderr}\n\n"
"This is your only chance to fix it. Re-check the file manifest above "
"for the real path/line count of anything flagged, then resubmit the "
"COMPLETE corrected narrative.json as raw JSON (same format as "
"before, not a diff) -- fix exactly the issues named above and "
"nothing else."
)})
narrative, gen_latency = _generate_narrative_once(messages, log)
latency += gen_latency
if narrative is None:
return "Fix attempt didn't produce a valid response:\n\n" + "\n".join(log_lines), None, gr.update()
def _summary_markdown(narrative: dict, ok: bool, latency: float, log_lines: list[str]) -> str:
case = narrative.get("case", {})
nodes = narrative.get("nodes", [])
lines = [
f"## {case.get('paper_title', '(untitled)')}",
f"*{case.get('venue', '')}* · `{case.get('repo', '')}`",
"",
"**Verified: yes** — every citation checked against the real code." if ok else
"**Verifier found an issue** — see the log below; treat citations with care.",
f"Model call time: {latency:.1f}s",
"",
"### Overview",
]
task = (case.get("overview") or {}).get("task", {})
lines.append(f"- **Input:** {task.get('input', '')}")
lines.append(f"- **Task:** {task.get('task', '')}")
lines.append(f"- **Output:** {task.get('output', '')}")
lines.append("")
lines.append("### Contributions")
for n in nodes:
lines.append(f"**{n.get('title')}** ({n.get('status')}) — {n.get('why', '')}")
for cr in n.get("code_refs", []):
lines.append(f" - `{cr.get('file')}:{cr.get('start_line')}-{cr.get('end_line')}`")
results = case.get("results", [])
if results:
lines.append("")
lines.append("### Results")
for t in results:
lines.append(f"**{t.get('table_label', '')}** — {t.get('caption', '')}")
cols = t.get("columns", [])
if cols:
lines.append("| " + " | ".join(cols) + " |")
lines.append("|" + "---|" * len(cols))
for row in t.get("rows", []):
lines.append("| " + " | ".join(row) + " |")
lines.append("")
lines.append("<details><summary>Full run log</summary>\n\n```\n" + "\n".join(log_lines)[-4000:] + "\n```\n</details>")
return "\n".join(lines)
def _list_case_choices() -> list[str]:
if not CASES_DIR.is_dir():
return []
return sorted(p.name for p in CASES_DIR.iterdir() if (p / "narrative.json").is_file())
def load_past_case(slug: str):
if not slug:
return "", None
narrative_path = CASES_DIR / slug / "narrative.json"
if not narrative_path.is_file():
return "No such case.", None
narrative = json.loads(narrative_path.read_text())
return _summary_markdown(narrative, True, 0.0, ["(loaded from disk, not re-run)"]), narrative
with gr.Blocks(title="Paper -> Code Grounding (Qwen3-VL, free ZeroGPU)") as demo:
gr.Markdown(
"# Paper -> Code Grounding\n"
"Runs **Qwen3-VL-32B-Instruct (FP8)** on Hugging Face's free ZeroGPU. "
"Only proceeds if the paper is real and links a GitHub repo.\n\n"
"**Heads up on quota**: ZeroGPU gives every visitor's own HF account a small daily GPU budget "
"(2 min if you're not logged in, 5 min on a free account) -- a real run here takes roughly a "
"minute or two, so expect a handful of tries per day, not unlimited use."
)
with gr.Row():
url_in = gr.Textbox(label="Paper URL (arXiv link)", placeholder="https://arxiv.org/abs/XXXX.XXXXX", scale=4)
submit_btn = gr.Button("Ground it", variant="primary", scale=1)
with gr.Row():
past_cases = gr.Dropdown(label="Past cases", choices=_list_case_choices(), interactive=True)
output_md = gr.Markdown()
narrative_state = gr.JSON(visible=False)
submit_btn.click(ground_paper, inputs=[url_in], outputs=[output_md, narrative_state, past_cases])
past_cases.change(load_past_case, inputs=[past_cases], outputs=[output_md, narrative_state])
if __name__ == "__main__":
demo.queue().launch()
|