parsearena 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,450 @@
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>parsearena report</title>
7
+ <meta name="description" content="PDF parsers side by side on your own documents, with retrieval metrics.">
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='1' y='1' width='22' height='22' rx='6' fill='%230064d2'/%3E%3Crect x='6.2' y='5' width='11.6' height='14' rx='1.6' fill='none' stroke='%23fff' stroke-width='2.1'/%3E%3Cpath d='M12 7.6v8.8' fill='none' stroke='%23fff' stroke-width='2.1' stroke-linecap='round'/%3E%3C/svg%3E">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
12
+ <style>
13
+ :root {
14
+ --bg: #ffffff; --surface: #ffffff; --surface-2: #f6f7f9; --border: #e4e4e9;
15
+ --text: #111116; --muted: #5c5d66; --accent: #0064d2; --on-accent: #ffffff;
16
+ --accent-soft: rgba(0, 100, 210, .09); --tile: #0064d2;
17
+ --ok: #11813c; --warn: #a45e00; --err: #c22826;
18
+ --ok-soft: rgba(17, 129, 60, .10); --warn-soft: rgba(164, 94, 0, .11); --err-soft: rgba(194, 40, 38, .09);
19
+ --mark: rgba(0, 100, 210, .16);
20
+ --shadow: 0 1px 2px rgba(17, 17, 22, .04), 0 4px 16px rgba(17, 17, 22, .05);
21
+ --radius: 12px;
22
+ --font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
23
+ --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
24
+ color-scheme: light;
25
+ }
26
+ @media (prefers-color-scheme: dark) {
27
+ :root:not([data-theme="light"]) {
28
+ --bg: #000000; --surface: #0d0d10; --surface-2: #141418; --border: #28282e;
29
+ --text: #f4f5f8; --muted: #a3a4ae; --accent: #70b0ff; --on-accent: #000000;
30
+ --accent-soft: rgba(112, 176, 255, .12);
31
+ --ok: #5fd37f; --warn: #f7b83d; --err: #f66d67;
32
+ --ok-soft: rgba(95, 211, 127, .12); --warn-soft: rgba(247, 184, 61, .12); --err-soft: rgba(246, 109, 103, .12);
33
+ --mark: rgba(112, 176, 255, .26);
34
+ --shadow: 0 1px 2px rgba(0, 0, 0, .6), 0 6px 24px rgba(0, 0, 0, .5);
35
+ color-scheme: dark;
36
+ }
37
+ }
38
+ :root[data-theme="dark"] {
39
+ --bg: #000000; --surface: #0d0d10; --surface-2: #141418; --border: #28282e;
40
+ --text: #f4f5f8; --muted: #a3a4ae; --accent: #70b0ff; --on-accent: #000000;
41
+ --accent-soft: rgba(112, 176, 255, .12);
42
+ --ok: #5fd37f; --warn: #f7b83d; --err: #f66d67;
43
+ --ok-soft: rgba(95, 211, 127, .12); --warn-soft: rgba(247, 184, 61, .12); --err-soft: rgba(246, 109, 103, .12);
44
+ --mark: rgba(112, 176, 255, .26);
45
+ --shadow: 0 1px 2px rgba(0, 0, 0, .6), 0 6px 24px rgba(0, 0, 0, .5);
46
+ color-scheme: dark;
47
+ }
48
+ * { box-sizing: border-box; }
49
+ html { -webkit-text-size-adjust: 100%; }
50
+ body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.55 var(--font); }
51
+ a { color: var(--accent); text-underline-offset: 2px; }
52
+ .wrap { max-width: 1320px; margin: 0 auto; padding: 0 24px 64px; }
53
+ header.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 0 8px; flex-wrap: wrap; }
54
+ .brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
55
+ .brand svg { width: 34px; height: 34px; flex: none; }
56
+ .brand b { font-size: 1.25rem; font-weight: 700; letter-spacing: -.01em; }
57
+ .brand span { display: block; color: var(--muted); font-size: .82rem; }
58
+ .toggles { display: flex; gap: 8px; }
59
+ .btn { appearance: none; border: 1px solid var(--border); background: var(--surface); color: var(--text); font: 500 .85rem var(--font);
60
+ padding: 7px 12px; border-radius: 8px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; }
61
+ .btn:hover { border-color: var(--accent); }
62
+ .btn:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
63
+ .btn svg { width: 16px; height: 16px; }
64
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); }
65
+ .seg button { appearance: none; border: 0; background: transparent; color: var(--muted); font: 600 .8rem var(--font); padding: 7px 11px; cursor: pointer; min-height: 34px; }
66
+ .seg button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); }
67
+ .recorded { margin: 12px 0 28px; padding: 12px 16px; border: 1px solid var(--border); border-left: 3px solid var(--accent);
68
+ border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .88rem; color: var(--muted); }
69
+ .recorded strong { color: var(--text); }
70
+ h2 { font-size: 1.15rem; margin: 40px 0 6px; letter-spacing: -.01em; }
71
+ .sub { color: var(--muted); margin: 0 0 14px; font-size: .9rem; max-width: 80ch; }
72
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
73
+ .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
74
+ table { border-collapse: collapse; width: 100%; font-size: .88rem; }
75
+ th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: middle; }
76
+ tr:last-child td { border-bottom: 0; }
77
+ th { font-weight: 600; color: var(--muted); font-size: .76rem; text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; }
78
+ td.num, th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
79
+ td.best { color: var(--accent); font-weight: 600; }
80
+ .pname { font-weight: 600; white-space: nowrap; }
81
+ .pver { color: var(--muted); font: .76rem var(--mono); margin-left: 6px; }
82
+ .bar { display: inline-block; width: 56px; height: 6px; border-radius: 3px; background: var(--surface-2); vertical-align: middle; margin-left: 8px; overflow: hidden; border: 1px solid var(--border); }
83
+ .bar i { display: block; height: 100%; background: var(--accent); }
84
+ .pill { display: inline-block; font: 500 .74rem var(--mono); padding: 2px 7px; border-radius: 6px; white-space: nowrap; }
85
+ .pill.ok { color: var(--ok); background: var(--ok-soft); }
86
+ .pill.warn { color: var(--warn); background: var(--warn-soft); }
87
+ .pill.err { color: var(--err); background: var(--err-soft); }
88
+ .controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
89
+ select { appearance: none; font: 500 .88rem var(--font); color: var(--text); background: var(--surface) 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='%238a8b94' stroke-width='2.5'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 10px center;
90
+ border: 1px solid var(--border); border-radius: 8px; padding: 7px 30px 7px 11px; min-height: 36px; max-width: 100%; }
91
+ .pager { display: inline-flex; align-items: center; gap: 6px; }
92
+ .pager span { font: 500 .85rem var(--mono); color: var(--muted); min-width: 64px; text-align: center; }
93
+ .viewer { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); gap: 14px; align-items: start; }
94
+ .pane { display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
95
+ .pane-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 8px 12px; border-bottom: 1px solid var(--border); min-height: 52px; }
96
+ .pane-head .t { font-weight: 600; font-size: .9rem; }
97
+ .pane-head select { font-size: .85rem; }
98
+ .pane-body { height: min(74vh, 860px); min-height: 420px; overflow: auto; }
99
+ .pane-img .pane-body { background: var(--surface-2); padding: 10px; display: flex; justify-content: center; align-items: flex-start; }
100
+ .pane-img img { width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border); background: #fff; display: block; }
101
+ pre.out { margin: 0; padding: 12px 14px; font: 12.5px/1.6 var(--mono); white-space: pre-wrap; word-break: break-word; color: var(--text); }
102
+ pre.out mark { background: var(--mark); color: inherit; border-radius: 3px; padding: 0 1px; }
103
+ .empty { padding: 28px 16px; color: var(--muted); font-size: .88rem; text-align: center; }
104
+ .empty.err { color: var(--err); }
105
+ .meta-line { font: .74rem var(--mono); color: var(--muted); }
106
+ .legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: .8rem; color: var(--muted); margin: 10px 0 0; }
107
+ .qcell { min-width: 260px; }
108
+ .qcell small { display: block; color: var(--muted); font: .76rem var(--mono); margin-top: 2px; }
109
+ .fail { padding: 12px 14px; border-bottom: 1px solid var(--border); font-size: .86rem; }
110
+ .fail:last-child { border-bottom: 0; }
111
+ .fail code { display: block; margin-top: 4px; font: .78rem/1.5 var(--mono); color: var(--err); white-space: pre-wrap; word-break: break-word; }
112
+ .foot { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--border); color: var(--muted); font-size: .8rem; }
113
+ .foot p { margin: 4px 0; }
114
+ .lic { columns: 2; column-gap: 32px; margin: 8px 0; padding-left: 18px; }
115
+ @media (max-width: 1100px) {
116
+ .viewer { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
117
+ .viewer .pane-b { display: none; }
118
+ }
119
+ @media (max-width: 720px) {
120
+ .wrap { padding: 0 16px 48px; }
121
+ .viewer { grid-template-columns: minmax(0, 1fr); }
122
+ .viewer .pane-b { display: flex; }
123
+ .pane-body { height: auto; max-height: 70vh; min-height: 0; }
124
+ .lic { columns: 1; }
125
+ h2 { margin-top: 32px; }
126
+ th, td { padding: 9px 10px; }
127
+ }
128
+ @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
129
+ </style>
130
+ </head>
131
+ <body>
132
+ <svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
133
+ <defs>
134
+ <symbol id="g-parse" viewBox="0 0 24 24">
135
+ <rect x="1" y="1" width="22" height="22" rx="6" fill="var(--tile)"/>
136
+ <rect x="6.2" y="5" width="11.6" height="14" rx="1.6" fill="none" stroke="var(--tile-fg)" stroke-width="2.1"/>
137
+ <path d="M12 7.6v8.8" fill="none" stroke="var(--tile-fg)" stroke-width="2.1" stroke-linecap="round"/>
138
+ </symbol>
139
+ </defs>
140
+ </svg>
141
+ <div class="wrap">
142
+ <header class="top">
143
+ <a class="brand" href="https://github.com/diegoperezg7/parsearena">
144
+ <svg style="--tile:#0064d2;--tile-fg:#ffffff" aria-hidden="true"><use href="#g-parse"/></svg>
145
+ <div><b>parsearena</b><span data-i18n="tagline"></span></div>
146
+ </a>
147
+ <div class="toggles">
148
+ <div class="seg" role="group" aria-label="Language">
149
+ <button type="button" data-lang="en">EN</button><button type="button" data-lang="es">ES</button>
150
+ </div>
151
+ <button class="btn" type="button" id="theme" aria-label="Toggle theme">
152
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
153
+ <span data-i18n="theme"></span>
154
+ </button>
155
+ </div>
156
+ </header>
157
+
158
+ <div class="recorded" id="recorded"></div>
159
+
160
+ <section>
161
+ <h2 data-i18n="h_summary"></h2>
162
+ <p class="sub" id="summary-sub"></p>
163
+ <div class="controls">
164
+ <div class="seg" role="group" aria-label="Retrieval method" id="method-seg"></div>
165
+ </div>
166
+ <div class="card table-wrap"><table id="summary"></table></div>
167
+ </section>
168
+
169
+ <section>
170
+ <h2 data-i18n="h_viewer"></h2>
171
+ <p class="sub" data-i18n="viewer_sub"></p>
172
+ <div class="controls">
173
+ <select id="doc" aria-label="Document"></select>
174
+ <div class="pager">
175
+ <button class="btn" type="button" id="prev" aria-label="Previous page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="m15 18-6-6 6-6"/></svg></button>
176
+ <span id="pageno"></span>
177
+ <button class="btn" type="button" id="next" aria-label="Next page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="m9 18 6-6-6-6"/></svg></button>
178
+ </div>
179
+ </div>
180
+ <div class="viewer">
181
+ <div class="card pane pane-img">
182
+ <div class="pane-head"><span class="t" data-i18n="original"></span><span class="meta-line" id="docmeta"></span></div>
183
+ <div class="pane-body"><img id="pageimg" alt=""></div>
184
+ </div>
185
+ <div class="card pane pane-a">
186
+ <div class="pane-head"><select id="pa" aria-label="Parser A"></select><span class="meta-line" id="ma"></span></div>
187
+ <div class="pane-body" id="ba"></div>
188
+ </div>
189
+ <div class="card pane pane-b">
190
+ <div class="pane-head"><select id="pb" aria-label="Parser B"></select><span class="meta-line" id="mb"></span></div>
191
+ <div class="pane-body" id="bb"></div>
192
+ </div>
193
+ </div>
194
+ <div class="legend"><span><mark style="background:var(--mark);padding:0 4px;border-radius:3px">abc</mark> <span data-i18n="legend_mark"></span></span></div>
195
+ </section>
196
+
197
+ <section>
198
+ <h2 data-i18n="h_questions"></h2>
199
+ <p class="sub" data-i18n="questions_sub"></p>
200
+ <div class="card table-wrap"><table id="questions"></table></div>
201
+ <div class="legend">
202
+ <span><span class="pill ok">#1</span> <span data-i18n="leg_ok"></span></span>
203
+ <span><span class="pill warn">#12</span> <span data-i18n="leg_warn"></span></span>
204
+ <span><span class="pill err" data-i18n="lost"></span> <span data-i18n="leg_err"></span></span>
205
+ </div>
206
+ </section>
207
+
208
+ <section>
209
+ <h2 data-i18n="h_timing"></h2>
210
+ <p class="sub" data-i18n="timing_sub"></p>
211
+ <div class="card table-wrap"><table id="timing"></table></div>
212
+ </section>
213
+
214
+ <section>
215
+ <h2 data-i18n="h_failures"></h2>
216
+ <div class="card" id="failures"></div>
217
+ </section>
218
+
219
+ <footer class="foot" id="foot"></footer>
220
+ </div>
221
+ <script id="data" type="application/json">__DATA__</script>
222
+ <script>
223
+ (function () {
224
+ "use strict";
225
+ var D = JSON.parse(document.getElementById("data").textContent);
226
+ var I18N = {
227
+ en: {
228
+ tagline: "PDF parsers side by side, measured on retrieval",
229
+ theme: "Theme",
230
+ h_summary: "Summary", h_viewer: "Page by page", h_questions: "Questions", h_timing: "Timing", h_failures: "Failures",
231
+ summary_sub: "Answer survives: the answer string is still in the parser output. Recall@{k}: a relevant chunk is in the top {k}. MRR: mean reciprocal rank of the first relevant chunk (cut at {c}). Same chunker ({chars} characters) for every parser. Hybrid fuses BM25 and dense by reciprocal rank, so it can land below both when they find different chunks.",
232
+ viewer_sub: "The rendered page next to what each parser extracted from it. Pick any two parsers; the page stays in sync.",
233
+ questions_sub: "Rank of the first chunk that contains the answer, for the selected retrieval method.",
234
+ timing_sub: "Seconds per page exclude model loading: each parser first converts an untimed two-page warm-up PDF (one text page, one image-only page), reported as warm-up.",
235
+ original: "Original page", parser: "Parser", survives: "Answer survives", recall: "Recall@{k}", mrr: "MRR",
236
+ spp: "s / page", failures: "Failures", device: "Device", warmup: "Warm-up", pages: "Pages", total: "Total",
237
+ question: "Question", lost: "lost", none: "No failures.", legend_mark: "answer strings found on this page",
238
+ leg_ok: "found in the top {k}", leg_warn: "found, below the top {k}", leg_err: "answer not in the output",
239
+ page: "page", chars: "chars", failed: "failed", empty_page: "Empty output for this page.",
240
+ recorded: "Recorded run", recorded_body: "Parser outputs recorded on {date} with parsearena {v} on {hw}. Retrieval metrics computed live on {now}.",
241
+ live: "Run", live_body: "Parsed on {date} with parsearena {v} on {hw}.",
242
+ bm25: "BM25", dense: "Dense", hybrid: "Hybrid",
243
+ foot_method: "Embeddings: {model} (fastembed, ONNX). Hybrid = reciprocal rank fusion of BM25 and dense. Every parser runs with its default settings in its own environment.",
244
+ foot_lic: "Parser licenses", doc_lic: "Documents",
245
+ not_run: "not run"
246
+ },
247
+ es: {
248
+ tagline: "Parsers de PDF lado a lado, medidos en recuperación",
249
+ theme: "Tema",
250
+ h_summary: "Resumen", h_viewer: "Página a página", h_questions: "Preguntas", h_timing: "Tiempos", h_failures: "Fallos",
251
+ summary_sub: "La respuesta sobrevive: el texto de la respuesta sigue en la salida del parser. Recall@{k}: un fragmento relevante está entre los {k} primeros. MRR: rango recíproco medio del primer fragmento relevante (corte en {c}). El mismo troceado ({chars} caracteres) para todos. La híbrida fusiona BM25 y densa por rango recíproco, así que puede quedar por debajo de ambas si encuentran fragmentos distintos.",
252
+ viewer_sub: "La página renderizada junto a lo que cada parser extrajo de ella. Elige dos parsers; la página va sincronizada.",
253
+ questions_sub: "Posición del primer fragmento que contiene la respuesta, con el método de recuperación elegido.",
254
+ timing_sub: "Los segundos por página no incluyen la carga de modelos: cada parser convierte antes, sin cronometrar, un PDF de calentamiento de dos páginas (una con texto y otra solo imagen).",
255
+ original: "Página original", parser: "Parser", survives: "Respuesta sobrevive", recall: "Recall@{k}", mrr: "MRR",
256
+ spp: "s / página", failures: "Fallos", device: "Dispositivo", warmup: "Calentamiento", pages: "Páginas", total: "Total",
257
+ question: "Pregunta", lost: "perdida", none: "Sin fallos.", legend_mark: "respuestas encontradas en esta página",
258
+ leg_ok: "encontrada entre los {k} primeros", leg_warn: "encontrada, por debajo de {k}", leg_err: "la respuesta no está en la salida",
259
+ page: "página", chars: "car.", failed: "falló", empty_page: "Salida vacía para esta página.",
260
+ recorded: "Ejecución grabada", recorded_body: "Salidas de los parsers grabadas el {date} con parsearena {v} en {hw}. Métricas de recuperación calculadas en vivo el {now}.",
261
+ live: "Ejecución", live_body: "Parseado el {date} con parsearena {v} en {hw}.",
262
+ bm25: "BM25", dense: "Densa", hybrid: "Híbrida",
263
+ foot_method: "Embeddings: {model} (fastembed, ONNX). Híbrida = fusión por rango recíproco de BM25 y densa. Cada parser corre con su configuración por defecto en su propio entorno.",
264
+ foot_lic: "Licencias de los parsers", doc_lic: "Documentos",
265
+ not_run: "sin ejecutar"
266
+ }
267
+ };
268
+ var store = {
269
+ get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
270
+ set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
271
+ };
272
+ var qlang = (location.search.match(/[?&]lang=(en|es)/) || [])[1];
273
+ var lang = qlang || store.get("pa-lang") || ((navigator.language || "en").toLowerCase().indexOf("es") === 0 ? "es" : "en");
274
+ var EV = D.evaluation, K = EV.k;
275
+ var method = EV.methods.indexOf("hybrid") >= 0 ? "hybrid" : "bm25";
276
+ var parsers = D.parsers.map(function (p) { return p.name; });
277
+ var state = { doc: D.docs[0] ? D.docs[0].id : null, page: 0, a: parsers[0], b: parsers[1] || parsers[0] };
278
+
279
+ function t(key, vars) {
280
+ var s = (I18N[lang] && I18N[lang][key]) || I18N.en[key] || key;
281
+ vars = Object.assign({ k: K, c: EV.mrr_cutoff, chars: EV.chunk_chars }, vars || {});
282
+ return s.replace(/\{(\w+)\}/g, function (_, v) { return vars[v] != null ? vars[v] : ""; });
283
+ }
284
+ function esc(s) { return String(s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
285
+ var HASQ = D.questions.length > 0;
286
+ function pct(x) { return HASQ ? Math.round(x * 100) + "%" : "–"; }
287
+ function fmt(x, d) { return x == null ? "–" : Number(x).toFixed(d == null ? 2 : d); }
288
+ function label(name) { var p = D.parsers.filter(function (q) { return q.name === name; })[0]; return p ? p.label : name; }
289
+ function ver(name) { var p = D.parsers.filter(function (q) { return q.name === name; })[0]; return p ? p.version : ""; }
290
+ function el(id) { return document.getElementById(id); }
291
+
292
+ function applyTheme(v) {
293
+ if (v) document.documentElement.setAttribute("data-theme", v); else document.documentElement.removeAttribute("data-theme");
294
+ }
295
+ applyTheme(store.get("pa-theme"));
296
+ el("theme").addEventListener("click", function () {
297
+ var cur = document.documentElement.getAttribute("data-theme") ||
298
+ (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
299
+ var next = cur === "dark" ? "light" : "dark";
300
+ applyTheme(next); store.set("pa-theme", next);
301
+ });
302
+ Array.prototype.forEach.call(document.querySelectorAll("[data-lang]"), function (b) {
303
+ b.addEventListener("click", function () { lang = b.getAttribute("data-lang"); store.set("pa-lang", lang); renderAll(); });
304
+ });
305
+
306
+ function renderStatic() {
307
+ document.documentElement.lang = lang;
308
+ Array.prototype.forEach.call(document.querySelectorAll("[data-i18n]"), function (n) { n.textContent = t(n.getAttribute("data-i18n")); });
309
+ Array.prototype.forEach.call(document.querySelectorAll("[data-lang]"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-lang") === lang)); });
310
+ var m = D.meta, vars = { date: String(m.created).replace("T", " ").replace(/:\d\dZ$/, " UTC"), v: m.tool_version, hw: m.hardware, now: EV.computed };
311
+ el("recorded").innerHTML = "<strong>" + esc(t(m.recorded ? "recorded" : "live")) + ".</strong> " + esc(t(m.recorded ? "recorded_body" : "live_body", vars));
312
+ el("summary-sub").textContent = t("summary_sub");
313
+ var seg = el("method-seg"); seg.innerHTML = "";
314
+ EV.methods.forEach(function (mm) {
315
+ var b = document.createElement("button"); b.type = "button"; b.textContent = t(mm);
316
+ b.setAttribute("aria-pressed", String(mm === method));
317
+ b.addEventListener("click", function () { method = mm; renderAll(); });
318
+ seg.appendChild(b);
319
+ });
320
+ }
321
+
322
+ function renderSummary() {
323
+ var rows = parsers.map(function (p) { return { p: p, s: EV.parsers[p].summary, tm: D.timings[p] }; });
324
+ var best = function (f, low) {
325
+ var vals = rows.map(f).filter(function (v) { return v != null; });
326
+ return vals.length ? (low ? Math.min.apply(null, vals) : Math.max.apply(null, vals)) : null;
327
+ };
328
+ var bS = best(function (r) { return r.s.survives; }), bR = best(function (r) { return r.s[method].recall; }),
329
+ bM = best(function (r) { return r.s[method].mrr; }), bT = best(function (r) { return r.tm.seconds_per_page; }, true);
330
+ var h = "<thead><tr><th>" + t("parser") + "</th><th class='num'>" + t("survives") + "</th><th class='num'>" + t("recall") +
331
+ " · " + t(method) + "</th><th class='num'>" + t("mrr") + " · " + t(method) + "</th><th class='num'>" + t("spp") +
332
+ "</th><th class='num'>" + t("failures") + "</th></tr></thead><tbody>";
333
+ rows.forEach(function (r) {
334
+ var nf = r.tm.failed.length;
335
+ h += "<tr><td><span class='pname'>" + esc(label(r.p)) + "</span><span class='pver'>" + esc(ver(r.p)) + "</span></td>" +
336
+ "<td class='num" + (r.s.survives === bS ? " best" : "") + "'>" + pct(r.s.survives) + "<span class='bar'><i style='width:" + (r.s.survives * 100).toFixed(0) + "%'></i></span></td>" +
337
+ "<td class='num" + (r.s[method].recall === bR ? " best" : "") + "'>" + pct(r.s[method].recall) + "</td>" +
338
+ "<td class='num" + (r.s[method].mrr === bM ? " best" : "") + "'>" + (HASQ ? fmt(r.s[method].mrr) : "–") + "</td>" +
339
+ "<td class='num" + (r.tm.seconds_per_page === bT ? " best" : "") + "'>" + fmt(r.tm.seconds_per_page, r.tm.seconds_per_page < 0.1 ? 3 : 1) + "</td>" +
340
+ "<td class='num'>" + (nf ? "<span class='pill err'>" + nf + "</span>" : "0") + "</td></tr>";
341
+ });
342
+ el("summary").innerHTML = h + "</tbody>";
343
+ }
344
+
345
+ function docById(id) { return D.docs.filter(function (d) { return d.id === id; })[0]; }
346
+
347
+ function highlight(text, doc) {
348
+ var html = esc(text);
349
+ var answers = [];
350
+ D.questions.forEach(function (q) { if (q.doc === doc) q.answers.forEach(function (a) { if (a.length >= 2) answers.push(a); }); });
351
+ answers.sort(function (x, y) { return y.length - x.length; });
352
+ if (!answers.length) return html;
353
+ var re = new RegExp(answers.map(function (a) { return esc(a).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }).join("|"), "gi");
354
+ return html.replace(re, function (m) { return "<mark>" + m + "</mark>"; });
355
+ }
356
+
357
+ function renderPane(which) {
358
+ var p = state[which], body = el(which === "a" ? "ba" : "bb"), meta = el(which === "a" ? "ma" : "mb");
359
+ var out = (D.outputs[p] || {})[state.doc];
360
+ if (!out) { body.innerHTML = "<div class='empty'>" + t("not_run") + "</div>"; meta.textContent = ""; return; }
361
+ if (out.error) {
362
+ body.innerHTML = "<div class='empty err'>" + esc(t("failed")) + ": " + esc(out.error) + "</div>"; meta.textContent = ""; return;
363
+ }
364
+ var text = out.pages[state.page] || "";
365
+ meta.textContent = text.length.toLocaleString() + " " + t("chars");
366
+ body.innerHTML = text.trim() ? "<pre class='out'>" + highlight(text, state.doc) + "</pre>" : "<div class='empty'>" + t("empty_page") + "</div>";
367
+ body.scrollTop = 0;
368
+ }
369
+
370
+ function renderViewer() {
371
+ var docSel = el("doc");
372
+ docSel.innerHTML = D.docs.map(function (d) { return "<option value='" + esc(d.id) + "'" + (d.id === state.doc ? " selected" : "") + ">" + esc(d.title || d.file) + "</option>"; }).join("");
373
+ ["a", "b"].forEach(function (w) {
374
+ el("p" + w).innerHTML = parsers.map(function (p) { return "<option value='" + esc(p) + "'" + (p === state[w] ? " selected" : "") + ">" + esc(label(p)) + "</option>"; }).join("");
375
+ });
376
+ var d = docById(state.doc);
377
+ if (!d) return;
378
+ el("pageno").textContent = (state.page + 1) + " / " + d.num_pages;
379
+ el("prev").disabled = state.page === 0; el("next").disabled = state.page >= d.num_pages - 1;
380
+ var img = el("pageimg");
381
+ img.src = d.images[state.page]; img.alt = (d.title || d.file) + ", " + t("page") + " " + (state.page + 1);
382
+ el("docmeta").textContent = d.file;
383
+ renderPane("a"); renderPane("b");
384
+ }
385
+ el("doc").addEventListener("change", function (e) { state.doc = e.target.value; state.page = 0; renderViewer(); });
386
+ el("pa").addEventListener("change", function (e) { state.a = e.target.value; renderPane("a"); });
387
+ el("pb").addEventListener("change", function (e) { state.b = e.target.value; renderPane("b"); });
388
+ el("prev").addEventListener("click", function () { if (state.page > 0) { state.page--; renderViewer(); } });
389
+ el("next").addEventListener("click", function () { var d = docById(state.doc); if (state.page < d.num_pages - 1) { state.page++; renderViewer(); } });
390
+ document.addEventListener("keydown", function (e) {
391
+ if (e.target.tagName === "SELECT") return;
392
+ if (e.key === "ArrowLeft") el("prev").click();
393
+ if (e.key === "ArrowRight") el("next").click();
394
+ });
395
+
396
+ function renderQuestions() {
397
+ var h = "<thead><tr><th>" + t("question") + "</th>" + parsers.map(function (p) { return "<th class='num'>" + esc(label(p)) + "</th>"; }).join("") + "</tr></thead><tbody>";
398
+ D.questions.forEach(function (q, i) {
399
+ var d = docById(q.doc);
400
+ h += "<tr><td class='qcell'>" + esc(q.q) + "<small>" + esc(q.answers.join(" | ")) + " · " + esc(d ? (d.title || d.file) : q.doc) + "</small></td>";
401
+ parsers.forEach(function (p) {
402
+ var r = EV.parsers[p].questions[i], rank = r.ranks[method], cell;
403
+ if (!r.survives) cell = "<span class='pill err'>" + t("lost") + "</span>";
404
+ else if (rank == null) cell = "<span class='pill warn'>–</span>";
405
+ else cell = "<span class='pill " + (rank <= K ? "ok" : "warn") + "'>#" + rank + "</span>";
406
+ h += "<td class='num'>" + cell + "</td>";
407
+ });
408
+ h += "</tr>";
409
+ });
410
+ el("questions").innerHTML = h + "</tbody>";
411
+ }
412
+
413
+ function renderTiming() {
414
+ var h = "<thead><tr><th>" + t("parser") + "</th><th>" + t("device") + "</th><th class='num'>" + t("pages") + "</th><th class='num'>" + t("spp") +
415
+ "</th><th class='num'>" + t("total") + " (s)</th><th class='num'>" + t("warmup") + " (s)</th></tr></thead><tbody>";
416
+ parsers.forEach(function (p) {
417
+ var tm = D.timings[p];
418
+ h += "<tr><td><span class='pname'>" + esc(label(p)) + "</span></td><td class='meta-line'>" + esc(tm.device || "–") + "</td><td class='num'>" + tm.pages +
419
+ "</td><td class='num'>" + fmt(tm.seconds_per_page, tm.seconds_per_page < 0.1 ? 3 : 1) + "</td><td class='num'>" + fmt(tm.seconds, 1) +
420
+ "</td><td class='num'>" + fmt(tm.warmup_seconds, 1) + "</td></tr>";
421
+ });
422
+ el("timing").innerHTML = h + "</tbody>";
423
+ }
424
+
425
+ function renderFailures() {
426
+ var items = [];
427
+ parsers.forEach(function (p) { D.timings[p].failed.forEach(function (f) { items.push("<div class='fail'><strong>" + esc(label(p)) + "</strong> · " + esc(f.doc) + "<code>" + esc(f.error) + "</code></div>"); }); });
428
+ el("failures").innerHTML = items.length ? items.join("") : "<div class='empty'>" + t("none") + "</div>";
429
+ }
430
+
431
+ function renderFoot() {
432
+ var h = "<p>" + esc(t("foot_method", { model: EV.embed_model || "–" })) + "</p><p><strong>" + t("foot_lic") + "</strong></p><ul class='lic'>";
433
+ D.parsers.forEach(function (p) { h += "<li><a href='" + esc(p.url) + "'>" + esc(p.label) + "</a> " + esc(p.version) + ": " + esc(p.license) + "</li>"; });
434
+ h += "</ul>";
435
+ var lic = D.docs.filter(function (d) { return d.license; });
436
+ if (lic.length) {
437
+ h += "<p><strong>" + t("doc_lic") + "</strong></p><ul class='lic'>";
438
+ lic.forEach(function (d) { h += "<li>" + (d.source ? "<a href='" + esc(d.source) + "'>" + esc(d.title || d.file) + "</a>" : esc(d.title || d.file)) + ": " + esc(d.license) + "</li>"; });
439
+ h += "</ul>";
440
+ }
441
+ h += "<p><a href='https://github.com/diegoperezg7/parsearena'>github.com/diegoperezg7/parsearena</a> · MIT</p>";
442
+ el("foot").innerHTML = h;
443
+ }
444
+
445
+ function renderAll() { renderStatic(); renderSummary(); renderViewer(); renderQuestions(); renderTiming(); renderFailures(); renderFoot(); }
446
+ renderAll();
447
+ })();
448
+ </script>
449
+ </body>
450
+ </html>
@@ -0,0 +1,197 @@
1
+ """Retrieval over parser outputs: BM25, local dense embeddings and their fusion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import Counter
7
+ from collections.abc import Sequence
8
+ from typing import Protocol
9
+
10
+ import numpy as np
11
+
12
+ from .text import tokenize
13
+
14
+ DEFAULT_EMBED_MODEL = "BAAI/bge-small-en-v1.5"
15
+
16
+
17
+ class BM25:
18
+ """Okapi BM25 (k1=1.5, b=0.75) over pre-tokenized documents."""
19
+
20
+ def __init__(self, docs: Sequence[str], k1: float = 1.5, b: float = 0.75) -> None:
21
+ self.k1, self.b = k1, b
22
+ self.tfs = [Counter(tokenize(d)) for d in docs]
23
+ self.lens = [sum(tf.values()) for tf in self.tfs]
24
+ self.avg = (sum(self.lens) / len(self.lens)) if self.lens else 0.0
25
+ df: Counter[str] = Counter()
26
+ for tf in self.tfs:
27
+ df.update(tf.keys())
28
+ n = len(docs)
29
+ self.idf = {t: math.log(1 + (n - f + 0.5) / (f + 0.5)) for t, f in df.items()}
30
+
31
+ def scores(self, query: str) -> list[float]:
32
+ terms = tokenize(query)
33
+ out = []
34
+ for tf, length in zip(self.tfs, self.lens, strict=True):
35
+ s = 0.0
36
+ denom_base = self.k1 * (1 - self.b + self.b * length / self.avg) if self.avg else self.k1
37
+ for t in terms:
38
+ f = tf.get(t)
39
+ if f:
40
+ s += self.idf[t] * f * (self.k1 + 1) / (f + denom_base)
41
+ out.append(s)
42
+ return out
43
+
44
+
45
+ class Embedder(Protocol):
46
+ name: str
47
+
48
+ def embed(self, texts: Sequence[str]) -> np.ndarray: ...
49
+
50
+ def embed_queries(self, texts: Sequence[str]) -> np.ndarray: ...
51
+
52
+
53
+ class FastEmbedder:
54
+ """Local ONNX embeddings through fastembed. No torch, no network after the first download."""
55
+
56
+ def __init__(self, model: str = DEFAULT_EMBED_MODEL, cache_dir: str | None = None) -> None:
57
+ quiet_hf_hub()
58
+ from fastembed import TextEmbedding
59
+
60
+ self.name = model
61
+ self._model = TextEmbedding(model_name=model, cache_dir=cache_dir)
62
+ self._store = VectorCache(cache_dir, model) if cache_dir else None
63
+
64
+ def _cached(self, kind: str, texts: Sequence[str], fn) -> np.ndarray:
65
+ if self._store is None:
66
+ return _unit(np.array(list(fn(list(texts))), dtype=np.float32))
67
+ keys = [self._store.key(kind, t) for t in texts]
68
+ missing = [t for k, t in zip(keys, texts, strict=True) if k not in self._store]
69
+ if missing:
70
+ vecs = _unit(np.array(list(fn(list(dict.fromkeys(missing)))), dtype=np.float32))
71
+ for t, v in zip(dict.fromkeys(missing), vecs, strict=True):
72
+ self._store.put(self._store.key(kind, t), v)
73
+ self._store.save()
74
+ return np.stack([self._store.get(k) for k in keys])
75
+
76
+ def embed(self, texts: Sequence[str]) -> np.ndarray:
77
+ if not texts:
78
+ return np.zeros((0, 1), dtype=np.float32)
79
+ return self._cached("p", texts, self._model.passage_embed)
80
+
81
+ def embed_queries(self, texts: Sequence[str]) -> np.ndarray:
82
+ return self._cached("q", texts, self._model.query_embed)
83
+
84
+
85
+ class VectorCache:
86
+ """Embeddings already computed, keyed by model and text hash, in one .npz per model.
87
+
88
+ The first run computes every vector; later runs over the same text reuse them.
89
+ """
90
+
91
+ def __init__(self, cache_dir: str, model: str) -> None:
92
+ from pathlib import Path
93
+
94
+ slug = "".join(c if c.isalnum() else "-" for c in model)
95
+ self.path = Path(cache_dir) / "vectors" / f"{slug}.npz"
96
+ self._data: dict[str, np.ndarray] = {}
97
+ self._dirty = False
98
+ if self.path.exists():
99
+ try:
100
+ with np.load(self.path) as f:
101
+ self._data = {k: f[k] for k in f.files}
102
+ except (OSError, ValueError):
103
+ self._data = {}
104
+
105
+ @staticmethod
106
+ def key(kind: str, text: str) -> str:
107
+ import hashlib
108
+
109
+ return kind + hashlib.sha256(text.encode("utf-8")).hexdigest()[:32]
110
+
111
+ def __contains__(self, key: str) -> bool:
112
+ return key in self._data
113
+
114
+ def get(self, key: str) -> np.ndarray:
115
+ return self._data[key]
116
+
117
+ def put(self, key: str, vec: np.ndarray) -> None:
118
+ self._data[key] = vec
119
+ self._dirty = True
120
+
121
+ def save(self) -> None:
122
+ if not self._dirty:
123
+ return
124
+ self.path.parent.mkdir(parents=True, exist_ok=True)
125
+ tmp = self.path.with_suffix(".tmp.npz")
126
+ np.savez(tmp, **self._data)
127
+ tmp.replace(self.path)
128
+ self._dirty = False
129
+
130
+
131
+ def quiet_hf_hub() -> None:
132
+ """Silence Hugging Face Hub progress bars and warnings; the CLI prints its own line."""
133
+ import logging
134
+ import os
135
+ import warnings
136
+
137
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
138
+ os.environ.setdefault("HF_HUB_VERBOSITY", "error")
139
+ os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
140
+ os.environ.setdefault("TQDM_DISABLE", "1")
141
+ logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
142
+ warnings.filterwarnings("ignore", module="huggingface_hub")
143
+ try:
144
+ from huggingface_hub.utils import disable_progress_bars
145
+ from huggingface_hub.utils import logging as hf_logging
146
+
147
+ disable_progress_bars()
148
+ hf_logging.set_verbosity_error()
149
+ except Exception: # noqa: BLE001 - only cosmetic
150
+ pass
151
+ try:
152
+ from loguru import logger
153
+
154
+ logger.disable("fastembed")
155
+ except Exception: # noqa: BLE001
156
+ pass
157
+
158
+
159
+ def model_is_cached(model: str, cache_dir: str) -> bool:
160
+ from pathlib import Path
161
+
162
+ key = model.split("/")[-1].lower()
163
+ root = Path(cache_dir)
164
+ return root.exists() and any(key in p.name.lower() for p in root.iterdir() if p.is_dir())
165
+
166
+
167
+ def _unit(m: np.ndarray) -> np.ndarray:
168
+ norms = np.linalg.norm(m, axis=1, keepdims=True)
169
+ norms[norms == 0] = 1.0
170
+ return m / norms
171
+
172
+
173
+ def score_ranks(scores: Sequence[float], drop_zero: bool = False) -> dict[int, int]:
174
+ """Rank of each item, best = 1. Tied scores share a rank (1, 2, 2, 4).
175
+
176
+ With ``drop_zero`` items scoring 0 are left out: for BM25 a zero score means
177
+ the chunk shares no term with the query, so it was not retrieved at all and
178
+ must not get a position (or a share of a fusion) from its place in the corpus.
179
+ """
180
+ items = [(i, s) for i, s in enumerate(scores) if not (drop_zero and s <= 0)]
181
+ items.sort(key=lambda t: -t[1])
182
+ ranks: dict[int, int] = {}
183
+ prev, rank = None, 0
184
+ for pos, (i, s) in enumerate(items, start=1):
185
+ if s != prev:
186
+ rank, prev = pos, s
187
+ ranks[i] = rank
188
+ return ranks
189
+
190
+
191
+ def rrf(rank_maps: Sequence[dict[int, int]], k: int = 60) -> dict[int, float]:
192
+ """Reciprocal rank fusion: sum of 1 / (k + rank) over the lists an item appears in."""
193
+ fused: dict[int, float] = {}
194
+ for ranks in rank_maps:
195
+ for idx, rank in ranks.items():
196
+ fused[idx] = fused.get(idx, 0.0) + 1.0 / (k + rank)
197
+ return fused