"""Pulpie vs Dripper — encoder vs decoder content extraction, live. gradio.Server backend: a FastAPI app with Gradio's queue engine layered on (via @app.api()). The UI is a custom vanilla HTML/CSS/JS frontend served at `/`; the Gradio JS client calls the queued `/race` endpoint, so requests go through concurrency control and (on ZeroGPU Spaces) GPU allocation — not raw fetches that would collide on the GPU. Both models receive the SAME simplified HTML (pulpie's MinerU-HTML-parity simplify). Pulpie classifies every block in one encoder forward pass; Dripper (MinerU-HTML 0.6B) emits its answer autoregressively, one token at a time. """ from __future__ import annotations import os import re import threading import time from pathlib import Path import spaces import torch from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer from fastapi.responses import HTMLResponse from gradio import Server from gradio.data_classes import FileData from pulpie.chunker import extract_blocks, pack_chunks, tokenize_blocks from pulpie.markdown import to_markdown from pulpie.model_utils import ( extract_item_ids, load_model_and_tokenizer, predictions_to_labels, resolve_model_id, ) from pulpie.reconstruct import extract_main_html from pulpie.simplify import simplify PULPIE_MODEL = "feyninc/pulpie-orange-small" DRIPPER_MODEL = "opendatalab/MinerU-HTML-v1.1-hunyuan0.5B-compact" # Dripper's real context is 32768 (matches the benchmark harness setting); # leave headroom for the answer tokens. DRIPPER_MAX_INPUT_TOKENS = 32768 - 8192 DRIPPER_MAX_NEW_TOKENS = 8192 MAX_HTML_BYTES = 2_000_000 EXAMPLES_DIR = Path(__file__).parent / "examples" EXAMPLES = { "paulgraham.com": "01_article_pg.html", "nodejs.dev": "02_nodejs_dev.html", "protobuf.dev": "05_protobuf_dev.html", "istio.io": "09_istio_io.html", "tutorialspoint.com": "11_tutorialspoint_com.html", "builtin.com": "12_builtin_com.html", "bbc.com": "15_bbc_com.html", "theguardian.com": "17_theguardian_com.html", "wikipedia.org": "14_wikipedia_org.html", "stackoverflow.com": "16_stackoverflow_com.html", } # MinerU-HTML's own "short_compact" prompt (build_prompt.py, Apache-2.0) — # the format Dripper was trained on. DRIPPER_PROMPT = """As an HTML expert, classify elements with "_item_id" as "main" or "other", keeping only the main content and removing nav, metadata, etc. Guidelines: "Main": Includes primary content like article text, images in the article, original posts in forums, Q&A questions, and answers. "Other": Includes navigation, metadata, ads, user info, and related content (e.g., sidebars, timestamps, suggested articles). Output Format: Return each _item_id and its corresponding category in the following format: {{_item_id}}{{category}}{{_item_id}}{{category}}... Here, {{_item_id}} may be 1, 2, 3..., and {{category}} is either "main" or "other", as shown in the example below: "1main2other3other4main" Input HTML: {html}""" # ── Model loading (CPU at startup; moved to CUDA inside @spaces.GPU) ── # ZeroGPU pattern: .to("cuda") at startup — spaces defers the actual move to # the GPU slice, so per-call transfer cost disappears. _STARTUP_DEVICE = "cuda" if (spaces.config.Config.zero_gpu or torch.cuda.is_available()) else "cpu" print("Loading pulpie orange-small...") _pulpie_model, _pulpie_tokenizer, _sep_token_id = load_model_and_tokenizer( resolve_model_id(PULPIE_MODEL), torch.device("cpu") ) _pulpie_model = _pulpie_model.to(_STARTUP_DEVICE) _pulpie_pad_id = _pulpie_model.config.pad_token_id or 0 print("Loading Dripper...") _dripper_tokenizer = AutoTokenizer.from_pretrained(DRIPPER_MODEL, trust_remote_code=True) _dripper_model = AutoModelForCausalLM.from_pretrained( DRIPPER_MODEL, dtype=torch.bfloat16, trust_remote_code=True ).to(_STARTUP_DEVICE) @spaces.GPU(duration=30) def pulpie_classify(simplified: str) -> tuple[dict[str, str], float]: """One (or few) encoder forward passes → (block labels, inference seconds).""" device = torch.device(_STARTUP_DEVICE) model = _pulpie_model blocks = extract_blocks(simplified) if not blocks: return {}, 0.0 item_ids = extract_item_ids(blocks) block_token_ids = tokenize_blocks(blocks, _pulpie_tokenizer) chunks = pack_chunks( block_token_ids, max_tokens=8192, sep_token_id=_sep_token_id, bos_token_id=_pulpie_tokenizer.bos_token_id, eos_token_id=_pulpie_tokenizer.eos_token_id, ) t0 = time.perf_counter() predictions = [0] * len(blocks) with torch.no_grad(): for chunk_ids, block_indices in chunks: input_ids = torch.tensor([chunk_ids], dtype=torch.long, device=device) attention_mask = torch.ones_like(input_ids) logits = model(input_ids=input_ids, attention_mask=attention_mask).logits[0] sep_positions = (input_ids[0] == _sep_token_id).nonzero(as_tuple=True)[0] preds = logits[sep_positions].argmax(dim=-1).cpu().tolist() for idx, block_idx in enumerate(block_indices): if idx < len(preds): predictions[block_idx] = preds[idx] if device.type == "cuda": torch.cuda.synchronize() return predictions_to_labels(item_ids, predictions), time.perf_counter() - t0 @spaces.GPU(duration=120) def dripper_generate_stream(simplified: str): """Decoder generation, yielding (accumulated_text, inference_seconds, error).""" device = torch.device(_STARTUP_DEVICE) model = _dripper_model prompt = DRIPPER_PROMPT.format(html=simplified) messages = [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] chat_prompt = _dripper_tokenizer.apply_chat_template( messages, tokenize=False, enable_thinking=False, add_generation_prompt=True ) inputs = _dripper_tokenizer(chat_prompt, return_tensors="pt") inputs.pop("token_type_ids", None) # tokenizer emits it; the causal LM rejects it if inputs.input_ids.shape[1] > DRIPPER_MAX_INPUT_TOKENS: yield None, 0.0, "PAGE TOO LONG FOR DRIPPER'S CONTEXT — a failure mode pulpie avoids." return inputs = {k: v.to(device) for k, v in inputs.items()} streamer = TextIteratorStreamer( _dripper_tokenizer, skip_prompt=True, skip_special_tokens=True ) thread = threading.Thread( target=model.generate, kwargs=dict( **inputs, max_new_tokens=DRIPPER_MAX_NEW_TOKENS, do_sample=False, streamer=streamer, ), ) t0 = time.perf_counter() thread.start() acc = "" for piece in streamer: acc += piece yield acc, time.perf_counter() - t0, None thread.join() yield acc, time.perf_counter() - t0, None def parse_dripper_labels(text: str) -> dict[str, str]: return {m.group(1): m.group(2) for m in re.finditer(r"(\d+)(main|other)", text)} def labels_to_markdown(map_html: str, labels: dict[str, str]) -> str: if not labels or not any(v == "main" for v in labels.values()): return "" return to_markdown(extract_main_html(map_html, labels)) # ── The race (queued endpoint, streamed as SSE via the Gradio client) ── MONO = "font-family: 'IBM Plex Mono', monospace;" def fmt_timer(seconds: float, done: bool, color: str = "#1B1714") -> str: state = "" if done else '● RUNNING ' return ( f'