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', '')}* &middot; `{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')}) &mdash; {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', '')}** &mdash; {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()