akhaliq HF Staff Claude commited on
Commit
efc2be9
Β·
1 Parent(s): d0b398e

Convert to gradio.Server: custom frontend, queued streaming backend

Browse files

app.py is now a gradio.Server FastAPI app: @app .api(name="race") wraps
the race as a generator endpoint (each yield -> one SSE event, so the
JS client sees pulpie finish, dripper stream live, then the verdict;
concurrency/GPU allocation handled by Gradio's queue). @app .get('/')
serves the new custom index.html. Inference logic and models unchanged.

index.html: self-contained vanilla HTML/CSS/JS frontend (~700 lines,
no build step) that calls the backend through the Gradio JS client
(Client.connect(origin) -> client.submit('/race')), so requests go
through the queue rather than raw fetch. Preserves the paper-light
design and adds: live streaming race, connection pill, progress bar +
button spinner, per-pane copy-md and LCS line-diff, raw-HTML accordion,
keyboard run (Enter / Cmd+Enter), error flash, empty/reset states,
marked+DOMPurify markdown rendering, responsive stacking.

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +46 -377
  2. index.html +536 -0
app.py CHANGED
@@ -1,5 +1,11 @@
1
  """Pulpie vs Dripper β€” encoder vs decoder content extraction, live.
2
 
 
 
 
 
 
 
3
  Both models receive the SAME simplified HTML (pulpie's MinerU-HTML-parity
4
  simplify). Pulpie classifies every block in one encoder forward pass; Dripper
5
  (MinerU-HTML 0.6B) emits its answer autoregressively, one token at a time.
@@ -7,15 +13,18 @@ simplify). Pulpie classifies every block in one encoder forward pass; Dripper
7
 
8
  from __future__ import annotations
9
 
 
10
  import re
11
  import threading
12
  import time
13
  from pathlib import Path
14
 
15
- import gradio as gr
16
  import spaces
17
  import torch
18
  from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
 
 
 
19
 
20
  from pulpie.chunker import extract_blocks, pack_chunks, tokenize_blocks
21
  from pulpie.markdown import to_markdown
@@ -174,7 +183,7 @@ def labels_to_markdown(map_html: str, labels: dict[str, str]) -> str:
174
  return to_markdown(extract_main_html(map_html, labels))
175
 
176
 
177
- # ── The race ──
178
 
179
  MONO = "font-family: 'IBM Plex Mono', monospace;"
180
 
@@ -188,23 +197,27 @@ def fmt_timer(seconds: float, done: bool, color: str = "#1B1714") -> str:
188
 
189
 
190
  def race(example_name: str, custom_html: str):
191
- """Generator driving both panes. Yields (pulpie_md, pulpie_timer, dripper_md, dripper_timer, verdict)."""
 
192
  if custom_html and custom_html.strip():
193
  html = custom_html
194
  elif example_name in EXAMPLES:
195
  html = (EXAMPLES_DIR / EXAMPLES[example_name]).read_text(errors="replace")
196
  else:
197
- yield "", "", "", "", "Pick an example page or paste HTML."
 
198
  return
199
  if len(html.encode()) > MAX_HTML_BYTES:
200
- yield "", "", "", "", "HTML too large (2 MB cap)."
 
201
  return
202
 
203
  # Shared preprocessing β€” identical input to both models.
204
  try:
205
  simplified, map_html = simplify(html)
206
  except Exception as e:
207
- yield "", "", "", "", f"simplify failed: {e}"
 
208
  return
209
 
210
  # Pulpie. Timed on-GPU inside the @spaces.GPU call so ZeroGPU
@@ -212,17 +225,22 @@ def race(example_name: str, custom_html: str):
212
  pulpie_labels, pulpie_s = pulpie_classify(simplified)
213
  pulpie_md = labels_to_markdown(map_html, pulpie_labels) or "*No main content detected.*"
214
  pulpie_timer = fmt_timer(pulpie_s, done=True, color="#C6531D")
215
- yield pulpie_md, pulpie_timer, "", fmt_timer(0.0, done=False), ""
 
216
 
217
  # Dripper, streaming.
218
  raw, dripper_s = "", 0.0
219
  for acc, gen_s, err in dripper_generate_stream(simplified):
220
  if err:
221
- yield pulpie_md, pulpie_timer, f"*{err}*", fmt_timer(gen_s, done=True), ""
 
 
222
  return
223
  raw, dripper_s = acc, gen_s
224
  preview = f"```\n…{raw[-1500:]}\n```" if raw else "*waiting for first token…*"
225
- yield pulpie_md, pulpie_timer, preview, fmt_timer(dripper_s, done=False), ""
 
 
226
 
227
  dripper_md = labels_to_markdown(map_html, parse_dripper_labels(raw)) or "*No main content detected.*"
228
  speedup = dripper_s / max(pulpie_s, 1e-6)
@@ -233,381 +251,32 @@ def race(example_name: str, custom_html: str):
233
  f"PULPIE {pulpie_s:.2f}s β†’ DRIPPER {dripper_s:.1f}s Β· "
234
  f'<span style="color:#C6531D; font-weight:600;">{speedup_str} ON THIS PAGE</span></div>'
235
  )
236
- yield pulpie_md, pulpie_timer, dripper_md, fmt_timer(dripper_s, done=True), verdict
237
-
238
-
239
- # ── UI ──
240
-
241
- CSS = """
242
- :root, .light, .dark {
243
- --paper: #F2EDE3; --raised: #FBF7F0; --sunken: #ECE5D7;
244
- --ink: #1B1714; --body-ink: #3F382F; --soft: #756A5E;
245
- --orange: #C6531D; --orange-hover: #E0823A; --pine: #2F5A3C;
246
- --hairline: #D7CBB6;
247
- color-scheme: light;
248
- /* Pin the Gradio variables dark mode flips β€” text must stay ink on paper. */
249
- --body-text-color: var(--ink);
250
- --body-text-color-subdued: var(--soft);
251
- --block-label-text-color: var(--soft);
252
- --block-label-background-fill: var(--paper);
253
- --block-background-fill: var(--raised);
254
- --block-title-text-color: var(--ink);
255
- --input-background-fill: var(--raised);
256
- --input-background-fill-focus: var(--raised);
257
- --input-border-color: var(--hairline);
258
- --body-background-fill: var(--paper);
259
- --background-fill-primary: var(--paper);
260
- --background-fill-secondary: var(--raised);
261
- --border-color-primary: var(--hairline);
262
- --block-border-color: var(--hairline);
263
- --table-text-color: var(--ink);
264
- --table-even-background-fill: var(--raised);
265
- --table-odd-background-fill: var(--paper);
266
- --button-primary-background-fill: var(--orange);
267
- --button-primary-background-fill-hover: var(--orange-hover);
268
- --button-primary-border-color: var(--orange);
269
- --button-primary-text-color: #FBF7F0;
270
- --button-secondary-background-fill: var(--paper);
271
- --button-secondary-background-fill-hover: var(--sunken);
272
- --button-secondary-border-color: var(--hairline);
273
- --button-secondary-text-color: var(--ink);
274
- --button-secondary-text-color-hover: var(--orange);
275
- --error-background-fill: #FFF3EC;
276
- --error-text-color: #8A2E16;
277
- --error-border-color: var(--orange);
278
- --error-icon-color: var(--orange);
279
- --error-500: var(--orange);
280
- --error-600: #A84418;
281
- /* Markdown prose has its own variable set β€” headings went white without these. */
282
- --prose-text-color: var(--body-ink);
283
- --prose-header-text-color: var(--ink);
284
- --link-text-color: var(--orange);
285
- --link-text-color-hover: var(--orange-hover);
286
- --link-text-color-visited: var(--orange);
287
- }
288
- /* Direct pins in case any prose rule bypasses the variables. */
289
- .result-pane h1, .result-pane h2, .result-pane h3,
290
- .result-pane h4, .result-pane h5, .result-pane h6,
291
- .prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
292
- color: var(--ink) !important;
293
- }
294
- .result-pane a, .prose a { color: var(--orange) !important; }
295
- .dark .gradio-container, .dark .gradio-container * { color: var(--ink); }
296
- html, body, gradio-app {
297
- background: var(--paper) !important;
298
- color: var(--ink) !important;
299
- }
300
- /* Paper everywhere β€” the page behind the container, not just the container. */
301
- body, gradio-app, .gradio-container, main, .main, .wrap, .contain {
302
- background: var(--paper) !important;
303
- }
304
- .gradio-container {
305
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif !important;
306
- color: var(--ink) !important;
307
- max-width: 1280px !important;
308
- margin: 0 auto !important;
309
- }
310
- .gradio-container * { border-radius: 0 !important; box-shadow: none !important; }
311
 
312
- #eyebrow {
313
- font-family: 'IBM Plex Mono', monospace; font-size: 12px;
314
- letter-spacing: 0.18em; color: var(--orange); text-transform: uppercase;
315
- }
316
- #headline h1 {
317
- font-weight: 700; letter-spacing: -0.035em; font-size: 44px;
318
- color: var(--ink); margin: 6px 0 4px;
319
- }
320
- #lead { font-family: 'Lora', serif; font-size: 19px; color: var(--body-ink); font-style: italic; }
321
- #header-block { border-bottom: 1px solid var(--hairline); padding-bottom: 24px; }
322
-
323
- .pane-label {
324
- font-family: 'IBM Plex Mono', monospace; font-size: 11px;
325
- letter-spacing: 0.12em; color: var(--soft); text-transform: uppercase;
326
- border-bottom: 1px solid var(--hairline); padding-bottom: 8px;
327
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
328
- }
329
- .result-pane {
330
- background: var(--raised) !important; border: 1px solid var(--hairline) !important;
331
- min-height: 220px; max-height: 560px; overflow-y: auto; padding: 18px !important;
332
- }
333
- /* Gradio nests blocks inside blocks β€” kill inner borders/padding so panes read as one surface. */
334
- .result-pane > *, .result-pane .prose, .result-pane .block {
335
- border: none !important; background: transparent !important; padding: 0 !important;
336
- }
337
- .result-pane h1, .result-pane h2, .result-pane h3 { font-weight: 700; letter-spacing: -0.02em; }
338
- .result-pane p { font-family: 'Lora', serif; color: var(--body-ink); }
339
-
340
- .input-eyebrow {
341
- font-family: 'IBM Plex Mono', monospace; font-size: 11px;
342
- letter-spacing: 0.14em; color: var(--soft); text-transform: uppercase;
343
- margin: 8px 0 6px;
344
- }
345
- /* Selector + button: same height, same hairline treatment, flush row.
346
- The dropdown's outer .block is Gradio's white card β€” flatten it entirely
347
- and let the input be the only visible surface. */
348
- #input-row { align-items: center; gap: 12px; }
349
- #input-row > * { margin: 0 !important; }
350
- /* Both children are exactly 48px and centered in the row β€” no stray offsets. */
351
- #input-row > .block, #input-row > button { align-self: center !important; }
352
- #input-row .svelte-1xfsv4t { margin: 0 !important; }
353
- #input-row .block {
354
- background: transparent !important; border: none !important; padding: 0 !important;
355
- overflow: visible !important;
356
- }
357
- #input-row .wrap, #input-row .wrap-inner, #input-row .secondary-wrap {
358
- background: transparent !important; border: none !important;
359
- padding: 0 !important; height: 100%;
360
- }
361
- #input-row input {
362
- background: var(--raised) !important;
363
- border: 1px solid var(--hairline) !important; height: 48px !important;
364
- font-family: 'IBM Plex Mono', monospace !important; font-size: 13px !important;
365
- color: var(--ink) !important; padding: 0 14px !important;
366
- cursor: pointer;
367
- }
368
- #input-row input:hover, #input-row input:focus {
369
- border-color: var(--orange) !important;
370
- }
371
- #input-row .icon-wrap { right: 14px; }
372
- #input-row .icon-wrap svg { fill: var(--soft); }
373
- #input-row button.primary { height: 48px !important; min-height: 48px !important; }
374
- /* Dropdown options list: paper card, hairline, mono β€” not Gradio's rounded white. */
375
- ul.options, .options {
376
- background: var(--raised) !important; border: 1px solid var(--hairline) !important;
377
- font-family: 'IBM Plex Mono', monospace !important; font-size: 13px !important;
378
- color: var(--ink) !important;
379
- }
380
- ul.options li.item, .options .item { padding: 10px 14px !important; }
381
- ul.options li.item.selected, .options .item.selected,
382
- ul.options li.item:hover, .options .item:hover {
383
- background: var(--sunken) !important; color: var(--orange) !important;
384
- }
385
 
386
- button.primary {
387
- background: var(--orange) !important; color: #FBF7F0 !important;
388
- font-family: 'IBM Plex Mono', monospace !important; letter-spacing: 0.1em;
389
- text-transform: uppercase; border: none !important;
390
- }
391
- button.primary, button.primary *, button.primary span { color: #FBF7F0 !important; }
392
- button.primary:hover { background: var(--orange-hover) !important; }
393
- button.secondary {
394
- background: var(--paper) !important; color: var(--ink) !important;
395
- border: 1px solid var(--hairline) !important;
396
- font-family: 'IBM Plex Mono', monospace !important; font-size: 12px !important;
397
- letter-spacing: 0.08em;
398
- }
399
- button.secondary:hover { color: var(--orange) !important; border-color: var(--orange) !important; }
400
 
401
- #bench-well {
402
- background: var(--sunken); border: 1px solid var(--hairline);
403
- padding: 20px 24px; margin-top: 8px;
404
- }
405
- #bench-well table { width: 100%; font-family: 'IBM Plex Mono', monospace; font-size: 13px; }
406
- #footer-mono {
407
- font-family: 'IBM Plex Mono', monospace; font-size: 12px; letter-spacing: 0.08em;
408
- color: var(--soft); border-top: 1px solid var(--hairline); padding-top: 16px;
409
- }
410
- #footer-mono a { color: var(--ink); text-decoration: none; }
411
- #footer-mono a:hover { color: var(--orange); }
412
- textarea { background: var(--raised) !important; border: 1px solid var(--hairline) !important; }
413
- input, textarea {
414
- color: var(--ink) !important;
415
- -webkit-text-fill-color: var(--ink) !important;
416
- }
417
- input::placeholder, textarea::placeholder {
418
- color: var(--soft) !important;
419
- opacity: 0.78 !important;
420
- }
421
- /* Accordion: hairline bar on paper, mono label β€” not a white card.
422
- The white comes from the .block wrapper Gradio puts around the accordion. */
423
- #html-accordion, #html-accordion.block {
424
- background: transparent !important;
425
- border: 1px solid var(--hairline) !important;
426
- }
427
- button.label-wrap { background: transparent !important; border: none !important; }
428
- /* Inner blocks of the accordion (the Textbox card) inherit paper, no border. */
429
- #html-accordion .block {
430
- background: transparent !important; border: none !important; padding: 0 !important;
431
- }
432
- button.label-wrap span {
433
- font-family: 'IBM Plex Mono', monospace !important; font-size: 11px !important;
434
- letter-spacing: 0.14em; text-transform: uppercase; color: var(--soft) !important;
435
- }
436
- button.label-wrap:hover span { color: var(--orange) !important; }
437
- #input-row svg, #html-accordion svg, button.label-wrap svg {
438
- color: var(--soft) !important;
439
- fill: var(--soft) !important;
440
- stroke: var(--soft) !important;
441
- }
442
- ul.options *, .options * { color: var(--ink) !important; }
443
- ul.options li.item.selected, .options .item.selected,
444
- ul.options li.item:hover, .options .item:hover {
445
- color: var(--orange) !important;
446
- }
447
- .result-pane li, .result-pane strong, .result-pane em,
448
- .result-pane blockquote, .result-pane code, .result-pane pre {
449
- color: var(--body-ink) !important;
450
- }
451
- .result-pane pre, .result-pane code {
452
- background: var(--sunken) !important;
453
- border-color: var(--hairline) !important;
454
- }
455
- .error, .validation-error {
456
- background: #FFF3EC !important;
457
- color: #8A2E16 !important;
458
- border-color: var(--orange) !important;
459
- }
460
- .toast-body {
461
- background: var(--raised) !important;
462
- color: var(--ink) !important;
463
- border: 1px solid var(--hairline) !important;
464
- }
465
- .toast-body.error {
466
- --toast-color: var(--orange) !important;
467
- border-color: var(--orange) !important;
468
- }
469
- .toast-title, .toast-count, .toast-message-text, .toast-close,
470
- .toast-body .toast-message-item, .toast-body * {
471
- color: var(--ink) !important;
472
- }
473
- .toast-icon, .toast-icon *, .toast-body.error .toast-icon,
474
- .toast-body.error .toast-icon * {
475
- color: var(--orange) !important;
476
- fill: var(--orange) !important;
477
- stroke: var(--orange) !important;
478
- }
479
- .toast-message-item {
480
- background: var(--paper) !important;
481
- }
482
- .toast-separator {
483
- background: var(--hairline) !important;
484
- }
485
- .timer {
486
- background: var(--orange) !important;
487
- }
488
- .icon-button-wrapper, button.icon-button, .icon-button {
489
- background: var(--raised) !important;
490
- color: var(--soft) !important;
491
- border-color: var(--hairline) !important;
492
- }
493
- button.icon-button:hover, .icon-button:hover {
494
- background: var(--sunken) !important;
495
- color: var(--orange) !important;
496
- border-color: var(--orange) !important;
497
- }
498
- .icon-button svg, button.icon-button svg,
499
- .icon-button-wrapper svg {
500
- color: currentColor !important;
501
- fill: currentColor !important;
502
- stroke: currentColor !important;
503
- }
504
- #vg-tooltip-element, [role="tooltip"] {
505
- background: var(--raised) !important;
506
- color: var(--ink) !important;
507
- border: 1px solid var(--hairline) !important;
508
- box-shadow: none !important;
509
- }
510
- #vg-tooltip-element *, [role="tooltip"] * {
511
- color: var(--ink) !important;
512
- }
513
- """
514
 
515
- # The design is paper-light only: strip Gradio's .dark class before paint and
516
- # whenever the runtime re-adds it (system dark-mode users otherwise get
517
- # near-white text on paper).
518
- FORCE_LIGHT_JS = """
519
- () => {
520
- const root = document.documentElement;
521
- const strip = () => { root.classList.remove('dark'); root.classList.add('light'); };
522
- strip();
523
- new MutationObserver(strip).observe(root, {attributes: true, attributeFilter: ['class']});
524
- }
525
- """
526
 
527
- # CSS goes in <head> (not launch(css=...)) so the first paint already has the
528
- # layout β€” otherwise the page renders at Gradio's default width, then snaps to
529
- # 1280px when the runtime-injected stylesheet lands.
530
- HEAD = f"""
531
- <link rel="preconnect" href="https://fonts.googleapis.com">
532
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
533
- <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Lora:ital@0;1&display=swap" rel="stylesheet">
534
- <style>{CSS}</style>
535
- """
536
 
537
- with gr.Blocks(title="pulpie β€” encoder vs decoder") as demo:
538
- with gr.Column(elem_id="header-block"):
539
- gr.HTML('<div id="eyebrow">FEYN Β· CONTENT EXTRACTION Β· ENCODER VS DECODER</div>')
540
- gr.HTML('<div id="headline"><h1>pulpie</h1></div>')
541
- gr.HTML(
542
- '<div id="lead">Extract main content from HTML. '
543
- "One forward pass, not one token at a time.</div>"
544
- )
545
-
546
- gr.HTML('<div class="input-eyebrow">EXAMPLE PAGE</div>')
547
- with gr.Row(elem_id="input-row"):
548
- example_dd = gr.Dropdown(
549
- choices=list(EXAMPLES.keys()),
550
- value="paulgraham.com",
551
- label=None,
552
- show_label=False,
553
- container=False,
554
- scale=2,
555
- )
556
- run_btn = gr.Button("EXTRACT β†’", variant="primary", scale=1)
557
- with gr.Accordion("OR PASTE RAW HTML", open=False, elem_id="html-accordion"):
558
- custom_html = gr.Textbox(
559
- lines=6,
560
- show_label=False,
561
- container=False,
562
- placeholder="<html>…</html> (2 MB max; JS-rendered SPAs will come up empty β€” nothing static to extract)",
563
- )
564
-
565
- with gr.Row():
566
- with gr.Column():
567
- gr.HTML('<div class="pane-label">PULPIE ORANGE-SMALL Β· 210M Β· ENCODER</div>')
568
- pulpie_timer = gr.HTML(fmt_timer(0.0, done=True))
569
- pulpie_out = gr.Markdown(
570
- "*One encoder forward pass over every block.*", elem_classes=["result-pane"]
571
- )
572
- with gr.Column():
573
- gr.HTML('<div class="pane-label">DRIPPER Β· 0.6B Β· DECODER</div>')
574
- dripper_timer = gr.HTML(fmt_timer(0.0, done=True))
575
- dripper_out = gr.Markdown(
576
- "*The same labels, one token at a time.*", elem_classes=["result-pane"]
577
- )
578
-
579
- verdict = gr.HTML()
580
-
581
- gr.HTML(
582
- """<div id="bench-well">
583
- <div style="font-family:'IBM Plex Mono',monospace; font-size:12px; letter-spacing:0.14em;
584
- color:#756A5E; margin-bottom:12px;">WEBMAINBENCH Β· 6,647 PAGES Β· ROUGE-5 F1</div>
585
- <table>
586
- <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>
587
- <tr><td>dripper (MinerU-HTML)</td><td>0.6B</td><td>0.864</td><td>0.68 pg/s on L4</td></tr>
588
- <tr><td>trafilatura</td><td>β€”</td><td>0.619</td><td>β€”</td></tr>
589
- </table>
590
- <div style="font-family:'Lora',serif; font-style:italic; color:#3F382F; margin-top:12px;">
591
- Same quality. ~$7,900 to clean 1B pages, versus ~$159,000.</div>
592
- </div>"""
593
- )
594
 
595
- gr.HTML(
596
- """<div id="footer-mono">
597
- pip install pulpie Β·
598
- <a href="https://github.com/feyninc/pulpie">GITHUB</a> Β·
599
- <a href="https://huggingface.co/feyninc/pulpie-orange-small">MODEL</a> Β·
600
- <a href="https://github.com/feyninc/pulpie/blob/main/BENCHMARKS.md">BENCHMARKS</a> Β·
601
- <a href="https://usefeyn.com/blog/pulpie-pareto-optimal-models-for-cleaning-the-web/">BLOG</a> Β·
602
- BUILT BY <a href="https://usefeyn.com">FEYN</a>
603
- </div>"""
604
- )
605
 
606
- run_btn.click(
607
- race,
608
- inputs=[example_dd, custom_html],
609
- outputs=[pulpie_out, pulpie_timer, dripper_out, dripper_timer, verdict],
610
- )
611
 
612
  if __name__ == "__main__":
613
- demo.launch(head=HEAD, js=FORCE_LIGHT_JS)
 
1
  """Pulpie vs Dripper β€” encoder vs decoder content extraction, live.
2
 
3
+ gradio.Server backend: a FastAPI app with Gradio's queue engine layered on
4
+ (via @app.api()). The UI is a custom vanilla HTML/CSS/JS frontend served at
5
+ `/`; the Gradio JS client calls the queued `/race` endpoint, so requests go
6
+ through concurrency control and (on ZeroGPU Spaces) GPU allocation β€” not raw
7
+ fetches that would collide on the GPU.
8
+
9
  Both models receive the SAME simplified HTML (pulpie's MinerU-HTML-parity
10
  simplify). Pulpie classifies every block in one encoder forward pass; Dripper
11
  (MinerU-HTML 0.6B) emits its answer autoregressively, one token at a time.
 
13
 
14
  from __future__ import annotations
15
 
16
+ import os
17
  import re
18
  import threading
19
  import time
20
  from pathlib import Path
21
 
 
22
  import spaces
23
  import torch
24
  from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
25
+ from fastapi.responses import HTMLResponse
26
+ from gradio import Server
27
+ from gradio.data_classes import FileData
28
 
29
  from pulpie.chunker import extract_blocks, pack_chunks, tokenize_blocks
30
  from pulpie.markdown import to_markdown
 
183
  return to_markdown(extract_main_html(map_html, labels))
184
 
185
 
186
+ # ── The race (queued endpoint, streamed as SSE via the Gradio client) ──
187
 
188
  MONO = "font-family: 'IBM Plex Mono', monospace;"
189
 
 
197
 
198
 
199
  def race(example_name: str, custom_html: str):
200
+ """Generator driving both panes. Yields dict records consumed by the SSE
201
+ frontend: {pulpie_md, pulpie_timer, dripper_md, dripper_timer, verdict}."""
202
  if custom_html and custom_html.strip():
203
  html = custom_html
204
  elif example_name in EXAMPLES:
205
  html = (EXAMPLES_DIR / EXAMPLES[example_name]).read_text(errors="replace")
206
  else:
207
+ yield {"pulpie_md": "", "pulpie_timer": "", "dripper_md": "",
208
+ "dripper_timer": "", "verdict": "Pick an example page or paste HTML."}
209
  return
210
  if len(html.encode()) > MAX_HTML_BYTES:
211
+ yield {"pulpie_md": "", "pulpie_timer": "", "dripper_md": "",
212
+ "dripper_timer": "", "verdict": "HTML too large (2 MB cap)."}
213
  return
214
 
215
  # Shared preprocessing β€” identical input to both models.
216
  try:
217
  simplified, map_html = simplify(html)
218
  except Exception as e:
219
+ yield {"pulpie_md": "", "pulpie_timer": "", "dripper_md": "",
220
+ "dripper_timer": "", "verdict": f"simplify failed: {e}"}
221
  return
222
 
223
  # Pulpie. Timed on-GPU inside the @spaces.GPU call so ZeroGPU
 
225
  pulpie_labels, pulpie_s = pulpie_classify(simplified)
226
  pulpie_md = labels_to_markdown(map_html, pulpie_labels) or "*No main content detected.*"
227
  pulpie_timer = fmt_timer(pulpie_s, done=True, color="#C6531D")
228
+ yield {"pulpie_md": pulpie_md, "pulpie_timer": pulpie_timer,
229
+ "dripper_md": "", "dripper_timer": fmt_timer(0.0, done=False), "verdict": ""}
230
 
231
  # Dripper, streaming.
232
  raw, dripper_s = "", 0.0
233
  for acc, gen_s, err in dripper_generate_stream(simplified):
234
  if err:
235
+ yield {"pulpie_md": pulpie_md, "pulpie_timer": pulpie_timer,
236
+ "dripper_md": f"*{err}*", "dripper_timer": fmt_timer(gen_s, done=True),
237
+ "verdict": ""}
238
  return
239
  raw, dripper_s = acc, gen_s
240
  preview = f"```\n…{raw[-1500:]}\n```" if raw else "*waiting for first token…*"
241
+ yield {"pulpie_md": pulpie_md, "pulpie_timer": pulpie_timer,
242
+ "dripper_md": preview, "dripper_timer": fmt_timer(dripper_s, done=False),
243
+ "verdict": ""}
244
 
245
  dripper_md = labels_to_markdown(map_html, parse_dripper_labels(raw)) or "*No main content detected.*"
246
  speedup = dripper_s / max(pulpie_s, 1e-6)
 
251
  f"PULPIE {pulpie_s:.2f}s β†’ DRIPPER {dripper_s:.1f}s Β· "
252
  f'<span style="color:#C6531D; font-weight:600;">{speedup_str} ON THIS PAGE</span></div>'
253
  )
254
+ yield {"pulpie_md": pulpie_md, "pulpie_timer": pulpie_timer,
255
+ "dripper_md": dripper_md, "dripper_timer": fmt_timer(dripper_s, done=True),
256
+ "verdict": verdict}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
+ # ── gradio.Server app ──
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
+ app = Server()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
 
 
 
 
 
 
 
 
 
 
 
263
 
264
+ @app.api(name="race")
265
+ def race_endpoint(example_name: str, custom_html: str):
266
+ """Queued, GPU-managed streaming endpoint. Each yielded record is delivered
267
+ to the Gradio client (JS or Python) as a separate SSE event, so the frontend
268
+ sees pulpie finish, then dripper's tokens arrive live, then the verdict.
269
+ The final record carries every field, so a non-streaming predict() call
270
+ still converges to the complete result."""
271
+ yield from race(example_name or "", custom_html or "")
 
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
+ @app.get("/")
275
+ async def homepage():
276
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
277
+ with open(html_path, "r", encoding="utf-8") as f:
278
+ return HTMLResponse(content=f.read())
 
 
 
 
 
279
 
 
 
 
 
 
280
 
281
  if __name__ == "__main__":
282
+ app.launch(show_error=True)
index.html ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>pulpie β€” encoder vs decoder</title>
7
+ <meta name="color-scheme" content="light" />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Lora:ital@0;1&display=swap" rel="stylesheet">
11
+ <style>
12
+ :root {
13
+ --paper: #F2EDE3; --raised: #FBF7F0; --sunken: #ECE5D7;
14
+ --ink: #1B1714; --body-ink: #3F382F; --soft: #756A5E;
15
+ --orange: #C6531D; --orange-hover: #E0823A; --pine: #2F5A3C;
16
+ --hairline: #D7CBB6;
17
+ --green: #2F5A3C;
18
+ color-scheme: light;
19
+ }
20
+ * { box-sizing: border-box; margin: 0; padding: 0; border-radius: 0; }
21
+ html, body { background: var(--paper); color: var(--ink); }
22
+ body {
23
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
24
+ min-height: 100vh; display: flex; flex-direction: column;
25
+ -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
26
+ }
27
+ .wrap { width: 100%; max-width: 1280px; margin: 0 auto; padding: 32px 28px 24px; flex: 1; }
28
+ .mono { font-family: 'IBM Plex Mono', monospace; }
29
+ .serif { font-family: 'Lora', serif; }
30
+
31
+ /* ── Header ── */
32
+ #header-block { border-bottom: 1px solid var(--hairline); padding-bottom: 24px; margin-bottom: 28px; }
33
+ #eyebrow {
34
+ font-family: 'IBM Plex Mono', monospace; font-size: 12px;
35
+ letter-spacing: 0.18em; color: var(--orange); text-transform: uppercase;
36
+ }
37
+ #headline h1 {
38
+ font-weight: 700; letter-spacing: -0.035em; font-size: 44px;
39
+ color: var(--ink); margin: 6px 0 4px; line-height: 1.05;
40
+ }
41
+ #lead { font-family: 'Lora', serif; font-size: 19px; color: var(--body-ink); font-style: italic; max-width: 680px; }
42
+ .pill {
43
+ display: inline-flex; align-items: center; gap: 6px; margin-top: 14px;
44
+ font-family: 'IBM Plex Mono', monospace; font-size: 11px; letter-spacing: 0.12em;
45
+ text-transform: uppercase; color: var(--soft);
46
+ background: var(--sunken); border: 1px solid var(--hairline); padding: 5px 10px;
47
+ }
48
+ .pill .dot { width: 6px; height: 6px; background: var(--green); border-radius: 50%; }
49
+ .pill.connecting .dot { background: var(--orange); animation: pulse 1s infinite; }
50
+ .pill.error .dot { background: #8A2E16; }
51
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
52
+
53
+ /* ── Input row ── */
54
+ .input-eyebrow {
55
+ font-family: 'IBM Plex Mono', monospace; font-size: 11px;
56
+ letter-spacing: 0.14em; color: var(--soft); text-transform: uppercase;
57
+ margin: 8px 0 10px;
58
+ }
59
+ #input-row { display: flex; align-items: stretch; gap: 12px; }
60
+ #example-select {
61
+ flex: 2; height: 48px; background: var(--raised);
62
+ border: 1px solid var(--hairline); color: var(--ink);
63
+ font-family: 'IBM Plex Mono', monospace; font-size: 13px;
64
+ padding: 0 14px; cursor: pointer; appearance: none;
65
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23756A5E' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
66
+ background-repeat: no-repeat; background-position: right 14px center;
67
+ padding-right: 38px; transition: border-color .15s;
68
+ }
69
+ #example-select:hover, #example-select:focus { border-color: var(--orange); outline: none; }
70
+ #run-btn {
71
+ flex: 1; height: 48px; min-width: 150px;
72
+ background: var(--orange); color: #FBF7F0; border: none; cursor: pointer;
73
+ font-family: 'IBM Plex Mono', monospace; font-size: 13px; font-weight: 600;
74
+ letter-spacing: 0.1em; text-transform: uppercase; transition: background .15s;
75
+ display: inline-flex; align-items: center; justify-content: center; gap: 10px;
76
+ }
77
+ #run-btn:hover:not(:disabled) { background: var(--orange-hover); }
78
+ #run-btn:disabled { opacity: 0.55; cursor: progress; }
79
+ .spinner {
80
+ width: 14px; height: 14px; border: 2px solid rgba(251,247,240,0.35);
81
+ border-top-color: #FBF7F0; border-radius: 50%;
82
+ animation: spin .7s linear infinite; display: none;
83
+ }
84
+ #run-btn.running .spinner { display: inline-block; }
85
+ @keyframes spin { to { transform: rotate(360deg); } }
86
+
87
+ /* ── Raw HTML accordion ── */
88
+ #html-accordion { margin-top: 14px; border: 1px solid var(--hairline); background: transparent; }
89
+ .acc-header {
90
+ display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer;
91
+ user-select: none; transition: background .15s;
92
+ }
93
+ .acc-header:hover { background: var(--sunken); }
94
+ .acc-header .chev {
95
+ transition: transform .2s; color: var(--soft);
96
+ font-family: 'IBM Plex Mono', monospace; font-size: 11px; letter-spacing: 0.14em;
97
+ text-transform: uppercase;
98
+ }
99
+ .acc-header .chev::before { content: "β–Έ "; }
100
+ #html-accordion.open .acc-header .chev::before { content: "β–Ύ "; }
101
+ .acc-body { display: none; padding: 0 14px 14px; }
102
+ #html-accordion.open .acc-body { display: block; }
103
+ #custom-html {
104
+ width: 100%; min-height: 120px; background: var(--raised);
105
+ border: 1px solid var(--hairline); color: var(--ink); padding: 12px 14px;
106
+ font-family: 'IBM Plex Mono', monospace; font-size: 12px; line-height: 1.5;
107
+ resize: vertical; transition: border-color .15s;
108
+ }
109
+ #custom-html:focus { border-color: var(--orange); outline: none; }
110
+ .acc-hint { font-family: 'Lora', serif; font-style: italic; color: var(--soft); font-size: 13px; margin-top: 8px; }
111
+
112
+ /* ── Panes ── */
113
+ .panes { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 28px; }
114
+ .pane { display: flex; flex-direction: column; }
115
+ .pane-label {
116
+ font-family: 'IBM Plex Mono', monospace; font-size: 11px;
117
+ letter-spacing: 0.12em; color: var(--soft); text-transform: uppercase;
118
+ border-bottom: 1px solid var(--hairline); padding-bottom: 8px; margin-bottom: 8px;
119
+ display: flex; align-items: center; gap: 8px;
120
+ }
121
+ .pane-label .badge { color: var(--orange); font-weight: 600; }
122
+ .result-pane {
123
+ background: var(--raised); border: 1px solid var(--hairline);
124
+ min-height: 320px; max-height: 580px; overflow-y: auto; padding: 18px;
125
+ position: relative;
126
+ }
127
+ .result-pane .placeholder { color: var(--soft); font-style: italic; font-family: 'Lora', serif; }
128
+ .timer {
129
+ font-family: 'IBM Plex Mono', monospace; font-size: 13px;
130
+ letter-spacing: 0.08em; color: var(--ink); min-height: 22px; margin-bottom: 4px;
131
+ }
132
+ .timer .running { color: var(--green); }
133
+ .timer.pulpie { color: var(--orange); }
134
+
135
+ /* markdown rendering inside panes */
136
+ .md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { color: var(--ink); font-weight: 700; letter-spacing: -0.02em; margin: 14px 0 6px; line-height: 1.25; }
137
+ .md h1 { font-size: 26px; } .md h2 { font-size: 22px; } .md h3 { font-size: 18px; } .md h4 { font-size: 16px; }
138
+ .md p { font-family: 'Lora', serif; color: var(--body-ink); margin: 8px 0; line-height: 1.6; }
139
+ .md ul, .md ol { margin: 8px 0 8px 22px; font-family: 'Lora', serif; color: var(--body-ink); }
140
+ .md li { margin: 3px 0; line-height: 1.55; }
141
+ .md a { color: var(--orange); }
142
+ .md a:hover { color: var(--orange-hover); }
143
+ .md code { background: var(--sunken); border: 1px solid var(--hairline); padding: 1px 5px; font-family: 'IBM Plex Mono', monospace; font-size: 0.88em; color: var(--ink); }
144
+ .md pre { background: var(--sunken); border: 1px solid var(--hairline); padding: 12px; overflow-x: auto; margin: 10px 0; }
145
+ .md pre code { background: none; border: none; padding: 0; }
146
+ .md blockquote { border-left: 3px solid var(--hairline); padding-left: 14px; margin: 10px 0; color: var(--body-ink); font-style: italic; font-family: 'Lora', serif; }
147
+ .md img { max-width: 100%; height: auto; }
148
+ .md table { border-collapse: collapse; margin: 10px 0; font-family: 'IBM Plex Mono', monospace; font-size: 13px; }
149
+ .md th, .md td { border: 1px solid var(--hairline); padding: 6px 10px; text-align: left; }
150
+ .md th { background: var(--sunken); }
151
+ .md hr { border: none; border-top: 1px solid var(--hairline); margin: 14px 0; }
152
+
153
+ /* verdict */
154
+ #verdict { margin-top: 18px; }
155
+ .verdict-bar {
156
+ font-family: 'IBM Plex Mono', monospace; font-size: 14px; letter-spacing: 0.08em;
157
+ padding: 14px 0; border-top: 1px solid var(--hairline); color: var(--ink);
158
+ }
159
+ .verdict-bar .speed { color: var(--orange); font-weight: 600; }
160
+
161
+ /* progress bar */
162
+ .progress-track { height: 3px; background: var(--sunken); margin-top: 4px; overflow: hidden; }
163
+ .progress-fill { height: 100%; width: 0%; background: var(--orange); transition: width .3s ease; }
164
+
165
+ /* pane action buttons */
166
+ .pane-actions { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
167
+ .mini-btn {
168
+ background: var(--paper); color: var(--ink); border: 1px solid var(--hairline);
169
+ font-family: 'IBM Plex Mono', monospace; font-size: 11px; letter-spacing: 0.08em;
170
+ padding: 6px 10px; cursor: pointer; transition: all .15s; text-transform: uppercase;
171
+ }
172
+ .mini-btn:hover { color: var(--orange); border-color: var(--orange); }
173
+ .mini-btn:disabled { opacity: 0.4; cursor: not-allowed; }
174
+ .mini-btn.active { color: var(--orange); border-color: var(--orange); background: var(--sunken); }
175
+
176
+ /* error flash */
177
+ .flash {
178
+ background: #FFF3EC; color: #8A2E16; border: 1px solid var(--orange);
179
+ padding: 12px 14px; font-family: 'IBM Plex Mono', monospace; font-size: 13px;
180
+ margin-top: 16px; display: none;
181
+ }
182
+ .flash.show { display: block; }
183
+
184
+ /* bench well */
185
+ #bench-well { background: var(--sunken); border: 1px solid var(--hairline); padding: 20px 24px; margin-top: 28px; }
186
+ #bench-well .label { font-family: 'IBM Plex Mono', monospace; font-size: 12px; letter-spacing: 0.14em; color: var(--soft); margin-bottom: 12px; }
187
+ #bench-well table { width: 100%; font-family: 'IBM Plex Mono', monospace; font-size: 13px; border-collapse: collapse; }
188
+ #bench-well td { padding: 6px 0; }
189
+ #bench-well td.win { color: var(--orange); font-weight: 600; }
190
+ #bench-well .tag { color: var(--soft); padding-left: 12px; }
191
+ #bench-well .note { font-family: 'Lora', serif; font-style: italic; color: var(--body-ink); margin-top: 12px; }
192
+
193
+ /* footer */
194
+ #footer-mono {
195
+ font-family: 'IBM Plex Mono', monospace; font-size: 12px; letter-spacing: 0.08em;
196
+ color: var(--soft); border-top: 1px solid var(--hairline); padding-top: 16px;
197
+ margin-top: 32px; padding-bottom: 8px;
198
+ }
199
+ #footer-mono a { color: var(--ink); text-decoration: none; }
200
+ #footer-mono a:hover { color: var(--orange); }
201
+
202
+ /* responsiveness */
203
+ @media (max-width: 820px) {
204
+ .wrap { padding: 24px 18px; }
205
+ #headline h1 { font-size: 34px; }
206
+ #lead { font-size: 17px; }
207
+ .panes { grid-template-columns: 1fr; }
208
+ #input-row { flex-direction: column; }
209
+ #example-select, #run-btn { flex: 1; width: 100%; min-width: 0; }
210
+ }
211
+ </style>
212
+ </head>
213
+ <body>
214
+ <div class="wrap">
215
+ <!-- Header -->
216
+ <div id="header-block">
217
+ <div id="eyebrow">FEYN Β· CONTENT EXTRACTION Β· ENCODER VS DECODER</div>
218
+ <div id="headline"><h1>pulpie</h1></div>
219
+ <div id="lead">Extract main content from HTML. One forward pass, not one token at a time.</div>
220
+ <div class="pill connecting" id="conn-pill">
221
+ <span class="dot"></span><span id="conn-text">CONNECTING</span>
222
+ </div>
223
+ </div>
224
+
225
+ <!-- Input -->
226
+ <div class="input-eyebrow">EXAMPLE PAGE</div>
227
+ <div id="input-row">
228
+ <select id="example-select" aria-label="Example page">
229
+ <option value="paulgraham.com">paulgraham.com</option>
230
+ <option value="nodejs.dev">nodejs.dev</option>
231
+ <option value="protobuf.dev">protobuf.dev</option>
232
+ <option value="istio.io">istio.io</option>
233
+ <option value="tutorialspoint.com">tutorialspoint.com</option>
234
+ <option value="builtin.com">builtin.com</option>
235
+ <option value="bbc.com">bbc.com</option>
236
+ <option value="theguardian.com">theguardian.com</option>
237
+ <option value="wikipedia.org">wikipedia.org</option>
238
+ <option value="stackoverflow.com">stackoverflow.com</option>
239
+ </select>
240
+ <button id="run-btn" class="primary">
241
+ <span class="spinner"></span>
242
+ <span class="btn-label">EXTRACT β†’</span>
243
+ </button>
244
+ </div>
245
+
246
+ <div id="html-accordion">
247
+ <div class="acc-header" id="acc-toggle" role="button" tabindex="0">
248
+ <span class="chev">OR PASTE RAW HTML</span>
249
+ </div>
250
+ <div class="acc-body">
251
+ <textarea id="custom-html" placeholder="<html>…</html> (2 MB max; JS-rendered SPAs will come up empty β€” nothing static to extract)"></textarea>
252
+ <div class="acc-hint">If set, this overrides the example dropdown.</div>
253
+ </div>
254
+ </div>
255
+
256
+ <!-- Panes -->
257
+ <div class="panes">
258
+ <div class="pane">
259
+ <div class="pane-label">PULPIE <span class="badge">ORANGE-SMALL Β· 210M Β· ENCODER</span></div>
260
+ <div class="timer pulpie" id="pulpie-timer"></div>
261
+ <div class="result-pane" id="pulpie-pane">
262
+ <div class="placeholder">One encoder forward pass over every block.</div>
263
+ </div>
264
+ <div class="pane-actions">
265
+ <button class="mini-btn" data-copy="pulpie">COPY MD</button>
266
+ <button class="mini-btn" data-diff="pulpie">SHOW DIFF</button>
267
+ </div>
268
+ </div>
269
+ <div class="pane">
270
+ <div class="pane-label">DRIPPER <span class="badge">0.6B Β· DECODER</span></div>
271
+ <div class="timer" id="dripper-timer"></div>
272
+ <div class="result-pane" id="dripper-pane">
273
+ <div class="placeholder">The same labels, one token at a time.</div>
274
+ </div>
275
+ <div class="pane-actions">
276
+ <button class="mini-btn" data-copy="dripper">COPY MD</button>
277
+ <button class="mini-btn" data-diff="dripper">SHOW DIFF</button>
278
+ </div>
279
+ </div>
280
+ </div>
281
+
282
+ <div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div>
283
+ <div id="verdict"></div>
284
+ <div class="flash" id="flash"></div>
285
+
286
+ <!-- Bench -->
287
+ <div id="bench-well">
288
+ <div class="label">WEBMAINBENCH Β· 6,647 PAGES Β· ROUGE-5 F1</div>
289
+ <table>
290
+ <tr><td>pulpie orange-small</td><td>210M</td><td class="win">0.862</td><td class="tag">13.7 pg/s on L4</td></tr>
291
+ <tr><td>dripper (MinerU-HTML)</td><td>0.6B</td><td>0.864</td><td class="tag">0.68 pg/s on L4</td></tr>
292
+ <tr><td>trafilatura</td><td>β€”</td><td>0.619</td><td class="tag">β€”</td></tr>
293
+ </table>
294
+ <div class="note">Same quality. ~$7,900 to clean 1B pages, versus ~$159,000.</div>
295
+ </div>
296
+
297
+ <!-- Footer -->
298
+ <div id="footer-mono">
299
+ pip install pulpie Β·
300
+ <a href="https://github.com/feyninc/pulpie">GITHUB</a> Β·
301
+ <a href="https://huggingface.co/feyninc/pulpie-orange-small">MODEL</a> Β·
302
+ <a href="https://github.com/feyninc/pulpie/blob/main/BENCHMARKS.md">BENCHMARKS</a> Β·
303
+ <a href="https://usefeyn.com/blog/pulpie-pareto-optimal-models-for-cleaning-the-web/">BLOG</a> Β·
304
+ BUILT BY <a href="https://usefeyn.com">FEYN</a>
305
+ </div>
306
+ </div>
307
+
308
+ <script type="module">
309
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
310
+ import { marked } from "https://cdn.jsdelivr.net/npm/marked@12/lib/marked.esm.js";
311
+ import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.es.mjs";
312
+
313
+ marked.setOptions({ gfm: true, breaks: false, headerIds: false, mangle: false });
314
+
315
+ // ── DOM refs ──
316
+ const $ = (id) => document.getElementById(id);
317
+ const runBtn = $("run-btn"), btnLabel = runBtn.querySelector(".btn-label");
318
+ const exampleSelect = $("example-select"), customHtml = $("custom-html");
319
+ const pulpiePane = $("pulpie-pane"), dripperPane = $("dripper-pane");
320
+ const pulpieTimer = $("pulpie-timer"), dripperTimer = $("dripper-timer");
321
+ const verdictEl = $("verdict"), flashEl = $("flash"), progressFill = $("progress-fill");
322
+ const connPill = $("conn-pill"), connText = $("conn-text");
323
+
324
+ const PLACEHOLDERS = {
325
+ pulpie: '<div class="placeholder">One encoder forward pass over every block.</div>',
326
+ dripper: '<div class="placeholder">The same labels, one token at a time.</div>',
327
+ };
328
+
329
+ // latest raw markdown, kept for copy/diff
330
+ let lastMd = { pulpie: "", dripper: "" };
331
+ let lastJob = null;
332
+
333
+ function renderMarkdown(md) {
334
+ if (!md) return "";
335
+ const html = marked.parse(md);
336
+ return DOMPurify.sanitize(html);
337
+ }
338
+
339
+ function setPane(pane, md, isRaw) {
340
+ if (!md) { pane.innerHTML = ""; return; }
341
+ // Backend sends timer HTML and verdict HTML inline; panes receive markdown text.
342
+ pane.innerHTML = isRaw ? md : renderMarkdown(md);
343
+ }
344
+
345
+ function setRunning(on) {
346
+ runBtn.classList.toggle("running", on);
347
+ runBtn.disabled = on;
348
+ btnLabel.textContent = on ? "EXTRACTING…" : "EXTRACT β†’";
349
+ }
350
+
351
+ function showError(msg) {
352
+ flashEl.textContent = msg;
353
+ flashEl.classList.add("show");
354
+ }
355
+ function clearError() { flashEl.classList.remove("show"); flashEl.textContent = ""; }
356
+
357
+ function setProgress(pct) { progressFill.style.width = Math.max(0, Math.min(100, pct)) + "%"; }
358
+
359
+ function resetPanes() {
360
+ pulpiePane.innerHTML = PLACEHOLDERS.pulpie;
361
+ dripperPane.innerHTML = PLACEHOLDERS.dripper;
362
+ pulpieTimer.innerHTML = "";
363
+ dripperTimer.innerHTML = "";
364
+ verdictEl.innerHTML = "";
365
+ lastMd = { pulpie: "", dripper: "" };
366
+ setProgress(0);
367
+ clearError();
368
+ }
369
+
370
+ // ── Streaming record normalizer (robust to dict or [dict] shapes) ──
371
+ function normalizeRecord(d) {
372
+ if (!d) return null;
373
+ if (typeof d === "object" && !Array.isArray(d) && "pulpie_md" in d) return d;
374
+ if (Array.isArray(d)) {
375
+ for (const item of d) {
376
+ if (item && typeof item === "object" && "pulpie_md" in item) return item;
377
+ }
378
+ }
379
+ return null;
380
+ }
381
+
382
+ function applyRecord(rec) {
383
+ if (!rec) return;
384
+ if (rec.pulpie_md !== undefined && rec.pulpie_md !== "") {
385
+ lastMd.pulpie = rec.pulpie_md;
386
+ setPane(pulpiePane, rec.pulpie_md);
387
+ }
388
+ if (rec.dripper_md !== undefined && rec.dripper_md !== "") {
389
+ lastMd.dripper = rec.dripper_md;
390
+ setPane(dripperPane, rec.dripper_md);
391
+ }
392
+ if (rec.pulpie_timer !== undefined) pulpieTimer.innerHTML = rec.pulpie_timer;
393
+ if (rec.dripper_timer !== undefined) dripperTimer.innerHTML = rec.dripper_timer;
394
+ if (rec.verdict !== undefined) verdictEl.innerHTML = rec.verdict;
395
+ // crude progress proxy: pulpie done = 40%, dripper running fills to 90%, final = 100%
396
+ if (rec.pulpie_md && !rec.dripper_md) setProgress(40);
397
+ else if (rec.dripper_md && rec.verdict) setProgress(100);
398
+ else if (rec.dripper_md) setProgress(90);
399
+ }
400
+
401
+ // ── Connect client ──
402
+ let client = null;
403
+ try {
404
+ client = await Client.connect(window.location.origin);
405
+ connPill.classList.remove("connecting");
406
+ connText.textContent = "READY";
407
+ } catch (e) {
408
+ connPill.classList.remove("connecting");
409
+ connPill.classList.add("error");
410
+ connText.textContent = "OFFLINE";
411
+ showError("Could not connect to backend: " + (e?.message || e));
412
+ }
413
+
414
+ // ── Run ──
415
+ async function run() {
416
+ if (!client) { showError("Backend not connected. Reload the page."); return; }
417
+ resetPanes();
418
+ setRunning(true);
419
+
420
+ const exampleName = exampleSelect.value;
421
+ const custom = customHtml.value.trim();
422
+ const params = { example_name: exampleName, custom_html: custom };
423
+
424
+ try {
425
+ // submit() returns a streaming Job; iterating delivers records as the
426
+ // generator yields them (live tokens + running timers). If streaming is
427
+ // unavailable on this client version, the job still resolves with the
428
+ // final record β€” applyRecord is idempotent and converges either way.
429
+ const job = client.submit("/race", params);
430
+ const iter = (typeof job[Symbol.asyncIterator] === "function")
431
+ ? job
432
+ : (job && typeof job.then === "function" ? await job : null);
433
+
434
+ if (iter && typeof iter[Symbol.asyncIterator] === "function") {
435
+ for await (const msg of iter) {
436
+ // msg may be { type, data } (gradio job) or the record itself
437
+ const payload = (msg && msg.type === "data" && msg.data !== undefined) ? msg.data
438
+ : (msg && msg.data !== undefined ? msg.data : msg);
439
+ applyRecord(normalizeRecord(payload));
440
+ }
441
+ } else if (iter) {
442
+ applyRecord(normalizeRecord(iter));
443
+ } else if (job && job.data !== undefined) {
444
+ applyRecord(normalizeRecord(job.data));
445
+ } else {
446
+ applyRecord(normalizeRecord(job));
447
+ }
448
+ if (!verdictEl.innerHTML) setProgress(100);
449
+ } catch (e) {
450
+ showError("Race failed: " + (e?.message || e));
451
+ setProgress(0);
452
+ } finally {
453
+ setRunning(false);
454
+ }
455
+ }
456
+
457
+ runBtn.addEventListener("click", run);
458
+
459
+ // Enter on the select runs; Ctrl/Cmd+Enter in the textarea runs.
460
+ exampleSelect.addEventListener("keydown", (e) => {
461
+ if (e.key === "Enter") { e.preventDefault(); run(); }
462
+ });
463
+ customHtml.addEventListener("keydown", (e) => {
464
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); run(); }
465
+ });
466
+
467
+ // ── Accordion ──
468
+ const acc = $("html-accordion");
469
+ $("acc-toggle").addEventListener("click", () => acc.classList.toggle("open"));
470
+ $("acc-toggle").addEventListener("keydown", (e) => {
471
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); acc.classList.toggle("open"); }
472
+ });
473
+
474
+ // ── Copy / Diff ──
475
+ document.querySelectorAll("[data-copy]").forEach((btn) => {
476
+ btn.addEventListener("click", async () => {
477
+ const which = btn.getAttribute("data-copy");
478
+ const text = lastMd[which];
479
+ if (!text) return;
480
+ try { await navigator.clipboard.writeText(text); btn.textContent = "COPIED βœ“"; }
481
+ catch { btn.textContent = "COPY FAILED"; }
482
+ setTimeout(() => (btn.textContent = "COPY MD"), 1400);
483
+ });
484
+ });
485
+
486
+ document.querySelectorAll("[data-diff]").forEach((btn) => {
487
+ btn.addEventListener("click", () => {
488
+ const which = btn.getAttribute("data-diff");
489
+ const other = which === "pulpie" ? "dripper" : "pulpie";
490
+ if (!lastMd[which] || !lastMd[other]) return;
491
+ const a = lastMd.pulpie, b = lastMd.dripper;
492
+ const same = a.trim() === b.trim();
493
+ const targetPane = which === "pulpie" ? pulpiePane : dripperPane;
494
+ if (same) {
495
+ targetPane.innerHTML = renderMarkdown(lastMd[which]) +
496
+ '<div style="font-family:\'IBM Plex Mono\',monospace;font-size:11px;color:var(--green);margin-top:14px;border-top:1px solid var(--hairline);padding-top:10px;">βœ“ IDENTICAL OUTPUTS</div>';
497
+ btn.classList.add("active");
498
+ setTimeout(() => { btn.classList.remove("active"); btn.textContent = "SHOW DIFF"; }, 2000);
499
+ } else {
500
+ const diff = lineDiff(a, b);
501
+ targetPane.innerHTML = '<pre style="white-space:pre-wrap;font-family:\'IBM Plex Mono\',monospace;font-size:12px;line-height:1.5;background:var(--sunken);border:1px solid var(--hairline);padding:12px;">' +
502
+ escapeHtml(diff) + "</pre>";
503
+ }
504
+ btn.textContent = same ? "IDENTICAL" : "TOGGLE BACK";
505
+ btn.addEventListener("click", function revert() {
506
+ if (lastMd[which]) targetPane.innerHTML = renderMarkdown(lastMd[which]);
507
+ btn.textContent = "SHOW DIFF";
508
+ btn.removeEventListener("click", revert);
509
+ }, { once: true });
510
+ });
511
+ });
512
+
513
+ // minimal LCS line diff for the diff view
514
+ function lineDiff(a, b) {
515
+ const la = a.split("\n"), lb = b.split("\n");
516
+ const n = la.length, m = lb.length;
517
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
518
+ for (let i = n - 1; i >= 0; i--)
519
+ for (let j = m - 1; j >= 0; j--)
520
+ dp[i][j] = la[i] === lb[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
521
+ let i = 0, j = 0, out = [];
522
+ while (i < n && j < m) {
523
+ if (la[i] === lb[j]) { out.push(" " + la[i]); i++; j++; }
524
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push("- " + la[i]); i++; }
525
+ else { out.push("+ " + lb[j]); j++; }
526
+ }
527
+ while (i < n) out.push("- " + la[i++]);
528
+ while (j < m) out.push("+ " + lb[j++]);
529
+ return out.slice(-400).join("\n");
530
+ }
531
+ function escapeHtml(s) {
532
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
533
+ }
534
+ </script>
535
+ </body>
536
+ </html>