pulpie / app.py
shreyashnigam's picture
Fix Gradio dark-mode error chrome
d0b398e verified
Raw
History Blame Contribute Delete
24.9 kB
"""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'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 ──
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
# Shared preprocessing — identical input to both models.
try:
simplified, map_html = simplify(html)
except Exception as e:
yield "", "", "", "", f"simplify failed: {e}"
return
# Pulpie. Timed on-GPU inside the @spaces.GPU call so ZeroGPU
# slice-attach overhead (identical for both models) isn't counted.
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), ""
# Dripper, streaming.
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
# ── UI ──
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;
}
"""
# The design is paper-light only: strip Gradio's .dark class before paint and
# whenever the runtime re-adds it (system dark-mode users otherwise get
# near-white text on paper).
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']});
}
"""
# CSS goes in <head> (not launch(css=...)) so the first paint already has the
# layout — otherwise the page renders at Gradio's default width, then snaps to
# 1280px when the runtime-injected stylesheet lands.
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)