| """Pulpie vs Dripper — encoder vs decoder content extraction, live. |
| |
| 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 re |
| import threading |
| import time |
| from pathlib import Path |
|
|
| import gradio as gr |
| import spaces |
| import torch |
| from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer |
|
|
| 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_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", |
| } |
|
|
| |
| |
| 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}""" |
|
|
| |
|
|
| |
| |
| _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) |
| 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)) |
|
|
|
|
| |
|
|
| MONO = "font-family: 'IBM Plex Mono', monospace;" |
|
|
|
|
| def fmt_timer(seconds: float, done: bool, color: str = "#1B1714") -> str: |
| state = "" if done else '<span style="color:#2F5A3C;">● RUNNING</span> ' |
| return ( |
| f'<div style="{MONO} font-size:13px; letter-spacing:0.08em; color:{color};">' |
| f"{state}{seconds:.1f}s</div>" |
| ) |
|
|
|
|
| def race(example_name: str, custom_html: str): |
| """Generator driving both panes. Yields (pulpie_md, pulpie_timer, dripper_md, dripper_timer, verdict).""" |
| if custom_html and custom_html.strip(): |
| html = custom_html |
| elif example_name in EXAMPLES: |
| html = (EXAMPLES_DIR / EXAMPLES[example_name]).read_text(errors="replace") |
| else: |
| yield "", "", "", "", "Pick an example page or paste HTML." |
| return |
| if len(html.encode()) > MAX_HTML_BYTES: |
| yield "", "", "", "", "HTML too large (2 MB cap)." |
| return |
|
|
| |
| try: |
| simplified, map_html = simplify(html) |
| except Exception as e: |
| yield "", "", "", "", f"simplify failed: {e}" |
| return |
|
|
| |
| |
| pulpie_labels, pulpie_s = pulpie_classify(simplified) |
| pulpie_md = labels_to_markdown(map_html, pulpie_labels) or "*No main content detected.*" |
| pulpie_timer = fmt_timer(pulpie_s, done=True, color="#C6531D") |
| yield pulpie_md, pulpie_timer, "", fmt_timer(0.0, done=False), "" |
|
|
| |
| raw, dripper_s = "", 0.0 |
| for acc, gen_s, err in dripper_generate_stream(simplified): |
| if err: |
| yield pulpie_md, pulpie_timer, f"*{err}*", fmt_timer(gen_s, done=True), "" |
| return |
| raw, dripper_s = acc, gen_s |
| preview = f"```\n…{raw[-1500:]}\n```" if raw else "*waiting for first token…*" |
| yield pulpie_md, pulpie_timer, preview, fmt_timer(dripper_s, done=False), "" |
|
|
| dripper_md = labels_to_markdown(map_html, parse_dripper_labels(raw)) or "*No main content detected.*" |
| speedup = dripper_s / max(pulpie_s, 1e-6) |
| speedup_str = f"{speedup:.1f}x" if speedup < 10 else f"{speedup:.0f}x" |
| verdict = ( |
| f'<div style="{MONO} font-size:14px; letter-spacing:0.08em; padding:14px 0; ' |
| f'border-top:1px solid #D7CBB6; color:#1B1714;">' |
| f"PULPIE {pulpie_s:.2f}s → DRIPPER {dripper_s:.1f}s · " |
| f'<span style="color:#C6531D; font-weight:600;">{speedup_str} ON THIS PAGE</span></div>' |
| ) |
| yield pulpie_md, pulpie_timer, dripper_md, fmt_timer(dripper_s, done=True), verdict |
|
|
|
|
| |
|
|
| CSS = """ |
| :root, .light, .dark { |
| --paper: #F2EDE3; --raised: #FBF7F0; --sunken: #ECE5D7; |
| --ink: #1B1714; --body-ink: #3F382F; --soft: #756A5E; |
| --orange: #C6531D; --orange-hover: #E0823A; --pine: #2F5A3C; |
| --hairline: #D7CBB6; |
| color-scheme: light; |
| /* Pin the Gradio variables dark mode flips — text must stay ink on paper. */ |
| --body-text-color: var(--ink); |
| --body-text-color-subdued: var(--soft); |
| --block-label-text-color: var(--soft); |
| --block-label-background-fill: var(--paper); |
| --block-background-fill: var(--raised); |
| --block-title-text-color: var(--ink); |
| --input-background-fill: var(--raised); |
| --input-background-fill-focus: var(--raised); |
| --input-border-color: var(--hairline); |
| --body-background-fill: var(--paper); |
| --background-fill-primary: var(--paper); |
| --background-fill-secondary: var(--raised); |
| --border-color-primary: var(--hairline); |
| --block-border-color: var(--hairline); |
| --table-text-color: var(--ink); |
| --table-even-background-fill: var(--raised); |
| --table-odd-background-fill: var(--paper); |
| --button-primary-background-fill: var(--orange); |
| --button-primary-background-fill-hover: var(--orange-hover); |
| --button-primary-border-color: var(--orange); |
| --button-primary-text-color: #FBF7F0; |
| --button-secondary-background-fill: var(--paper); |
| --button-secondary-background-fill-hover: var(--sunken); |
| --button-secondary-border-color: var(--hairline); |
| --button-secondary-text-color: var(--ink); |
| --button-secondary-text-color-hover: var(--orange); |
| --error-background-fill: #FFF3EC; |
| --error-text-color: #8A2E16; |
| --error-border-color: var(--orange); |
| --error-icon-color: var(--orange); |
| --error-500: var(--orange); |
| --error-600: #A84418; |
| /* Markdown prose has its own variable set — headings went white without these. */ |
| --prose-text-color: var(--body-ink); |
| --prose-header-text-color: var(--ink); |
| --link-text-color: var(--orange); |
| --link-text-color-hover: var(--orange-hover); |
| --link-text-color-visited: var(--orange); |
| } |
| /* Direct pins in case any prose rule bypasses the variables. */ |
| .result-pane h1, .result-pane h2, .result-pane h3, |
| .result-pane h4, .result-pane h5, .result-pane h6, |
| .prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 { |
| color: var(--ink) !important; |
| } |
| .result-pane a, .prose a { color: var(--orange) !important; } |
| .dark .gradio-container, .dark .gradio-container * { color: var(--ink); } |
| html, body, gradio-app { |
| background: var(--paper) !important; |
| color: var(--ink) !important; |
| } |
| /* Paper everywhere — the page behind the container, not just the container. */ |
| body, gradio-app, .gradio-container, main, .main, .wrap, .contain { |
| background: var(--paper) !important; |
| } |
| .gradio-container { |
| font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif !important; |
| color: var(--ink) !important; |
| max-width: 1280px !important; |
| margin: 0 auto !important; |
| } |
| .gradio-container * { border-radius: 0 !important; box-shadow: none !important; } |
| |
| #eyebrow { |
| font-family: 'IBM Plex Mono', monospace; font-size: 12px; |
| letter-spacing: 0.18em; color: var(--orange); text-transform: uppercase; |
| } |
| #headline h1 { |
| font-weight: 700; letter-spacing: -0.035em; font-size: 44px; |
| color: var(--ink); margin: 6px 0 4px; |
| } |
| #lead { font-family: 'Lora', serif; font-size: 19px; color: var(--body-ink); font-style: italic; } |
| #header-block { border-bottom: 1px solid var(--hairline); padding-bottom: 24px; } |
| |
| .pane-label { |
| font-family: 'IBM Plex Mono', monospace; font-size: 11px; |
| letter-spacing: 0.12em; color: var(--soft); text-transform: uppercase; |
| border-bottom: 1px solid var(--hairline); padding-bottom: 8px; |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| } |
| .result-pane { |
| background: var(--raised) !important; border: 1px solid var(--hairline) !important; |
| min-height: 220px; max-height: 560px; overflow-y: auto; padding: 18px !important; |
| } |
| /* Gradio nests blocks inside blocks — kill inner borders/padding so panes read as one surface. */ |
| .result-pane > *, .result-pane .prose, .result-pane .block { |
| border: none !important; background: transparent !important; padding: 0 !important; |
| } |
| .result-pane h1, .result-pane h2, .result-pane h3 { font-weight: 700; letter-spacing: -0.02em; } |
| .result-pane p { font-family: 'Lora', serif; color: var(--body-ink); } |
| |
| .input-eyebrow { |
| font-family: 'IBM Plex Mono', monospace; font-size: 11px; |
| letter-spacing: 0.14em; color: var(--soft); text-transform: uppercase; |
| margin: 8px 0 6px; |
| } |
| /* Selector + button: same height, same hairline treatment, flush row. |
| The dropdown's outer .block is Gradio's white card — flatten it entirely |
| and let the input be the only visible surface. */ |
| #input-row { align-items: center; gap: 12px; } |
| #input-row > * { margin: 0 !important; } |
| /* Both children are exactly 48px and centered in the row — no stray offsets. */ |
| #input-row > .block, #input-row > button { align-self: center !important; } |
| #input-row .svelte-1xfsv4t { margin: 0 !important; } |
| #input-row .block { |
| background: transparent !important; border: none !important; padding: 0 !important; |
| overflow: visible !important; |
| } |
| #input-row .wrap, #input-row .wrap-inner, #input-row .secondary-wrap { |
| background: transparent !important; border: none !important; |
| padding: 0 !important; height: 100%; |
| } |
| #input-row input { |
| background: var(--raised) !important; |
| border: 1px solid var(--hairline) !important; height: 48px !important; |
| font-family: 'IBM Plex Mono', monospace !important; font-size: 13px !important; |
| color: var(--ink) !important; padding: 0 14px !important; |
| cursor: pointer; |
| } |
| #input-row input:hover, #input-row input:focus { |
| border-color: var(--orange) !important; |
| } |
| #input-row .icon-wrap { right: 14px; } |
| #input-row .icon-wrap svg { fill: var(--soft); } |
| #input-row button.primary { height: 48px !important; min-height: 48px !important; } |
| /* Dropdown options list: paper card, hairline, mono — not Gradio's rounded white. */ |
| ul.options, .options { |
| background: var(--raised) !important; border: 1px solid var(--hairline) !important; |
| font-family: 'IBM Plex Mono', monospace !important; font-size: 13px !important; |
| color: var(--ink) !important; |
| } |
| ul.options li.item, .options .item { padding: 10px 14px !important; } |
| ul.options li.item.selected, .options .item.selected, |
| ul.options li.item:hover, .options .item:hover { |
| background: var(--sunken) !important; color: var(--orange) !important; |
| } |
| |
| button.primary { |
| background: var(--orange) !important; color: #FBF7F0 !important; |
| font-family: 'IBM Plex Mono', monospace !important; letter-spacing: 0.1em; |
| text-transform: uppercase; border: none !important; |
| } |
| button.primary, button.primary *, button.primary span { color: #FBF7F0 !important; } |
| button.primary:hover { background: var(--orange-hover) !important; } |
| button.secondary { |
| background: var(--paper) !important; color: var(--ink) !important; |
| border: 1px solid var(--hairline) !important; |
| font-family: 'IBM Plex Mono', monospace !important; font-size: 12px !important; |
| letter-spacing: 0.08em; |
| } |
| button.secondary:hover { color: var(--orange) !important; border-color: var(--orange) !important; } |
| |
| #bench-well { |
| background: var(--sunken); border: 1px solid var(--hairline); |
| padding: 20px 24px; margin-top: 8px; |
| } |
| #bench-well table { width: 100%; font-family: 'IBM Plex Mono', monospace; font-size: 13px; } |
| #footer-mono { |
| font-family: 'IBM Plex Mono', monospace; font-size: 12px; letter-spacing: 0.08em; |
| color: var(--soft); border-top: 1px solid var(--hairline); padding-top: 16px; |
| } |
| #footer-mono a { color: var(--ink); text-decoration: none; } |
| #footer-mono a:hover { color: var(--orange); } |
| textarea { background: var(--raised) !important; border: 1px solid var(--hairline) !important; } |
| input, textarea { |
| color: var(--ink) !important; |
| -webkit-text-fill-color: var(--ink) !important; |
| } |
| input::placeholder, textarea::placeholder { |
| color: var(--soft) !important; |
| opacity: 0.78 !important; |
| } |
| /* Accordion: hairline bar on paper, mono label — not a white card. |
| The white comes from the .block wrapper Gradio puts around the accordion. */ |
| #html-accordion, #html-accordion.block { |
| background: transparent !important; |
| border: 1px solid var(--hairline) !important; |
| } |
| button.label-wrap { background: transparent !important; border: none !important; } |
| /* Inner blocks of the accordion (the Textbox card) inherit paper, no border. */ |
| #html-accordion .block { |
| background: transparent !important; border: none !important; padding: 0 !important; |
| } |
| button.label-wrap span { |
| font-family: 'IBM Plex Mono', monospace !important; font-size: 11px !important; |
| letter-spacing: 0.14em; text-transform: uppercase; color: var(--soft) !important; |
| } |
| button.label-wrap:hover span { color: var(--orange) !important; } |
| #input-row svg, #html-accordion svg, button.label-wrap svg { |
| color: var(--soft) !important; |
| fill: var(--soft) !important; |
| stroke: var(--soft) !important; |
| } |
| ul.options *, .options * { color: var(--ink) !important; } |
| ul.options li.item.selected, .options .item.selected, |
| ul.options li.item:hover, .options .item:hover { |
| color: var(--orange) !important; |
| } |
| .result-pane li, .result-pane strong, .result-pane em, |
| .result-pane blockquote, .result-pane code, .result-pane pre { |
| color: var(--body-ink) !important; |
| } |
| .result-pane pre, .result-pane code { |
| background: var(--sunken) !important; |
| border-color: var(--hairline) !important; |
| } |
| .error, .validation-error { |
| background: #FFF3EC !important; |
| color: #8A2E16 !important; |
| border-color: var(--orange) !important; |
| } |
| .toast-body { |
| background: var(--raised) !important; |
| color: var(--ink) !important; |
| border: 1px solid var(--hairline) !important; |
| } |
| .toast-body.error { |
| --toast-color: var(--orange) !important; |
| border-color: var(--orange) !important; |
| } |
| .toast-title, .toast-count, .toast-message-text, .toast-close, |
| .toast-body .toast-message-item, .toast-body * { |
| color: var(--ink) !important; |
| } |
| .toast-icon, .toast-icon *, .toast-body.error .toast-icon, |
| .toast-body.error .toast-icon * { |
| color: var(--orange) !important; |
| fill: var(--orange) !important; |
| stroke: var(--orange) !important; |
| } |
| .toast-message-item { |
| background: var(--paper) !important; |
| } |
| .toast-separator { |
| background: var(--hairline) !important; |
| } |
| .timer { |
| background: var(--orange) !important; |
| } |
| .icon-button-wrapper, button.icon-button, .icon-button { |
| background: var(--raised) !important; |
| color: var(--soft) !important; |
| border-color: var(--hairline) !important; |
| } |
| button.icon-button:hover, .icon-button:hover { |
| background: var(--sunken) !important; |
| color: var(--orange) !important; |
| border-color: var(--orange) !important; |
| } |
| .icon-button svg, button.icon-button svg, |
| .icon-button-wrapper svg { |
| color: currentColor !important; |
| fill: currentColor !important; |
| stroke: currentColor !important; |
| } |
| #vg-tooltip-element, [role="tooltip"] { |
| background: var(--raised) !important; |
| color: var(--ink) !important; |
| border: 1px solid var(--hairline) !important; |
| box-shadow: none !important; |
| } |
| #vg-tooltip-element *, [role="tooltip"] * { |
| color: var(--ink) !important; |
| } |
| """ |
|
|
| |
| |
| |
| FORCE_LIGHT_JS = """ |
| () => { |
| const root = document.documentElement; |
| const strip = () => { root.classList.remove('dark'); root.classList.add('light'); }; |
| strip(); |
| new MutationObserver(strip).observe(root, {attributes: true, attributeFilter: ['class']}); |
| } |
| """ |
|
|
| |
| |
| |
| HEAD = f""" |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Lora:ital@0;1&display=swap" rel="stylesheet"> |
| <style>{CSS}</style> |
| """ |
|
|
| with gr.Blocks(title="pulpie — encoder vs decoder") as demo: |
| with gr.Column(elem_id="header-block"): |
| gr.HTML('<div id="eyebrow">FEYN · CONTENT EXTRACTION · ENCODER VS DECODER</div>') |
| gr.HTML('<div id="headline"><h1>pulpie</h1></div>') |
| gr.HTML( |
| '<div id="lead">Extract main content from HTML. ' |
| "One forward pass, not one token at a time.</div>" |
| ) |
|
|
| gr.HTML('<div class="input-eyebrow">EXAMPLE PAGE</div>') |
| with gr.Row(elem_id="input-row"): |
| example_dd = gr.Dropdown( |
| choices=list(EXAMPLES.keys()), |
| value="paulgraham.com", |
| label=None, |
| show_label=False, |
| container=False, |
| scale=2, |
| ) |
| run_btn = gr.Button("EXTRACT →", variant="primary", scale=1) |
| with gr.Accordion("OR PASTE RAW HTML", open=False, elem_id="html-accordion"): |
| custom_html = gr.Textbox( |
| lines=6, |
| show_label=False, |
| container=False, |
| placeholder="<html>…</html> (2 MB max; JS-rendered SPAs will come up empty — nothing static to extract)", |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(): |
| gr.HTML('<div class="pane-label">PULPIE ORANGE-SMALL · 210M · ENCODER</div>') |
| pulpie_timer = gr.HTML(fmt_timer(0.0, done=True)) |
| pulpie_out = gr.Markdown( |
| "*One encoder forward pass over every block.*", elem_classes=["result-pane"] |
| ) |
| with gr.Column(): |
| gr.HTML('<div class="pane-label">DRIPPER · 0.6B · DECODER</div>') |
| dripper_timer = gr.HTML(fmt_timer(0.0, done=True)) |
| dripper_out = gr.Markdown( |
| "*The same labels, one token at a time.*", elem_classes=["result-pane"] |
| ) |
|
|
| verdict = gr.HTML() |
|
|
| gr.HTML( |
| """<div id="bench-well"> |
| <div style="font-family:'IBM Plex Mono',monospace; font-size:12px; letter-spacing:0.14em; |
| color:#756A5E; margin-bottom:12px;">WEBMAINBENCH · 6,647 PAGES · ROUGE-5 F1</div> |
| <table> |
| <tr><td>pulpie orange-small</td><td>210M</td><td style="color:#C6531D; font-weight:600;">0.862</td><td>13.7 pg/s on L4</td></tr> |
| <tr><td>dripper (MinerU-HTML)</td><td>0.6B</td><td>0.864</td><td>0.68 pg/s on L4</td></tr> |
| <tr><td>trafilatura</td><td>—</td><td>0.619</td><td>—</td></tr> |
| </table> |
| <div style="font-family:'Lora',serif; font-style:italic; color:#3F382F; margin-top:12px;"> |
| Same quality. ~$7,900 to clean 1B pages, versus ~$159,000.</div> |
| </div>""" |
| ) |
|
|
| gr.HTML( |
| """<div id="footer-mono"> |
| pip install pulpie · |
| <a href="https://github.com/feyninc/pulpie">GITHUB</a> · |
| <a href="https://huggingface.co/feyninc/pulpie-orange-small">MODEL</a> · |
| <a href="https://github.com/feyninc/pulpie/blob/main/BENCHMARKS.md">BENCHMARKS</a> · |
| <a href="https://usefeyn.com/blog/pulpie-pareto-optimal-models-for-cleaning-the-web/">BLOG</a> · |
| BUILT BY <a href="https://usefeyn.com">FEYN</a> |
| </div>""" |
| ) |
|
|
| run_btn.click( |
| race, |
| inputs=[example_dd, custom_html], |
| outputs=[pulpie_out, pulpie_timer, dripper_out, dripper_timer, verdict], |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch(head=HEAD, js=FORCE_LIGHT_JS) |
|
|