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.
parsearena/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """parsearena: compare PDF parsers on your own documents and measure retrieval."""
2
+
3
+ __version__ = "0.1.3"
parsearena/cli.py ADDED
@@ -0,0 +1,288 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import time
8
+ import webbrowser
9
+ from importlib import resources
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from . import __version__
17
+ from .errors import UserError
18
+ from .evaluate import evaluate, timings
19
+ from .parsers import DEFAULT_PARSERS, PARSERS, env_dir, home, is_installed, missing_tools, parse_parser_list
20
+ from .pipeline import collect_docs, load_outputs, run_parser, save_outputs
21
+ from .questions import EXAMPLE, dump_questions, generate_questions, load_questions
22
+ from .retrieval import DEFAULT_EMBED_MODEL
23
+
24
+ app = typer.Typer(
25
+ add_completion=False,
26
+ no_args_is_help=True,
27
+ rich_markup_mode=None,
28
+ help="Run your PDFs through Docling, Marker, MinerU and Unstructured. "
29
+ "Compare the output side by side and see what each one does to retrieval.",
30
+ )
31
+ console = Console(highlight=False)
32
+ err = Console(stderr=True, highlight=False)
33
+
34
+
35
+ def _version(value: bool) -> None:
36
+ if value:
37
+ console.print(f"parsearena {__version__}")
38
+ raise typer.Exit()
39
+
40
+
41
+ @app.callback()
42
+ def _root(
43
+ version: bool = typer.Option(False, "--version", callback=_version, is_eager=True, help="Show version."),
44
+ ) -> None:
45
+ pass
46
+
47
+
48
+ def _embedder(no_dense: bool, model: str):
49
+ if no_dense:
50
+ return None
51
+ try:
52
+ from .retrieval import FastEmbedder, model_is_cached
53
+
54
+ cache = str(home() / "models")
55
+ if model_is_cached(model, cache):
56
+ msg = f"Loading embedding model {model}..."
57
+ else:
58
+ size = ", about 65 MB" if model == DEFAULT_EMBED_MODEL else ""
59
+ msg = f"Downloading embedding model {model} (one time{size})..."
60
+ console.print(msg)
61
+ with console.status(msg):
62
+ return FastEmbedder(model, cache_dir=cache)
63
+ except Exception as exc: # network errors, unknown model names
64
+ raise UserError(
65
+ f"Could not load the embedding model {model}: {type(exc).__name__}: {exc}"[:400],
66
+ "Check your connection for the first download, pick another --embed-model, "
67
+ "or use --no-dense for BM25 only.",
68
+ ) from exc
69
+
70
+
71
+ def _analyze(
72
+ recorded, pdf_paths, questions, out, k, no_dense, embed_model, chunk_chars, is_recorded, doc_info=None
73
+ ):
74
+ from .report import build_report
75
+
76
+ outputs = load_outputs(recorded)
77
+ embedder = _embedder(no_dense, embed_model)
78
+ with console.status("Computing retrieval metrics..."):
79
+ ev = evaluate(outputs, questions, k=k, embedder=embedder, chunk_chars=chunk_chars)
80
+ ev["embed_model"] = embedder.name if embedder else None
81
+ ev["questions"] = len(questions)
82
+ tm = timings(outputs, {d["id"]: d["num_pages"] for d in recorded["docs"]})
83
+ with console.status("Rendering pages and writing the viewer..."):
84
+ index = build_report(out, recorded, pdf_paths, questions, ev, tm, is_recorded, doc_info)
85
+ _print_summary(recorded, ev, tm)
86
+ return index
87
+
88
+
89
+ def _pct(x: float) -> str:
90
+ # Round half up, like the viewer (Math.round), so terminal and HTML agree.
91
+ return f"{int(x * 100 + 0.5)}%"
92
+
93
+
94
+ def _print_summary(recorded, ev, tm) -> None:
95
+ labels = {p["name"]: p["label"] for p in recorded["parsers"]}
96
+ k = ev["k"]
97
+ table = Table(title=None, header_style="bold", pad_edge=False)
98
+ table.add_column("parser")
99
+ table.add_column("survives", justify="right")
100
+ for m in ev["methods"]:
101
+ table.add_column(f"R@{k} {m}", justify="right")
102
+ table.add_column("MRR " + ev["methods"][-1], justify="right")
103
+ table.add_column("s/page", justify="right")
104
+ table.add_column("failed", justify="right")
105
+ has_q = ev.get("questions", 0) > 0
106
+ for p, res in ev["parsers"].items():
107
+ s, t = res["summary"], tm[p]
108
+ spp = t["seconds_per_page"]
109
+ row = [labels.get(p, p), _pct(s["survives"]) if has_q else "-"]
110
+ row += [_pct(s[m]["recall"]) if has_q else "-" for m in ev["methods"]]
111
+ row += [f"{s[ev['methods'][-1]]['mrr']:.2f}" if has_q else "-"]
112
+ row += ["-" if spp is None else (f"{spp:.3f}" if spp < 0.1 else f"{spp:.1f}")]
113
+ row += [str(len(t["failed"])) if t["failed"] else "0"]
114
+ table.add_row(*row)
115
+ console.print(table)
116
+ if not has_q:
117
+ console.print(
118
+ "[yellow]No questions given, so retrieval metrics are empty.[/] "
119
+ "Pass --questions FILE or --gen-questions."
120
+ )
121
+
122
+
123
+ def _open(index: Path, no_open: bool) -> None:
124
+ console.print(f"\nViewer: [bold]{index}[/]")
125
+ if not no_open:
126
+ webbrowser.open(index.resolve().as_uri())
127
+
128
+
129
+ @app.command()
130
+ def run(
131
+ pdfs: list[Path] = typer.Argument(..., help="PDF files to parse.", show_default=False),
132
+ parsers: str = typer.Option(
133
+ ",".join(DEFAULT_PARSERS), "--parsers", "-p", help="Comma-separated parsers."
134
+ ),
135
+ questions: Path | None = typer.Option(None, "--questions", "-q", help="YAML file with questions."),
136
+ gen_questions: bool = typer.Option(False, "--gen-questions", help="Draft questions with an LLM."),
137
+ per_doc: int = typer.Option(5, "--per-doc", help="Questions to draft per document."),
138
+ provider: str = typer.Option("ollama", "--provider", help="ollama, openrouter, openai or mistral."),
139
+ model: str = typer.Option("", "--model", help="Model for --gen-questions (default depends on provider)."),
140
+ max_cost: float = typer.Option(1.0, "--max-cost", help="Spending cap in USD for LLM calls."),
141
+ out: Path = typer.Option(Path("parsearena-out"), "--out", "-o", help="Output directory."),
142
+ k: int = typer.Option(5, "--k", help="k for recall@k."),
143
+ device: str = typer.Option("auto", "--device", help="auto, cpu, mps or cuda."),
144
+ timeout: int = typer.Option(3600, "--timeout", help="Seconds allowed per parser."),
145
+ chunk_chars: int = typer.Option(1200, "--chunk-chars", help="Maximum characters per chunk."),
146
+ embed_model: str = typer.Option(DEFAULT_EMBED_MODEL, "--embed-model", help="fastembed model name."),
147
+ no_dense: bool = typer.Option(False, "--no-dense", help="BM25 only, no embeddings."),
148
+ no_open: bool = typer.Option(False, "--no-open", help="Do not open the viewer."),
149
+ ) -> None:
150
+ """Parse PDFs with each parser in its own environment, then score retrieval."""
151
+ specs = parse_parser_list(parsers)
152
+ if device not in ("auto", "cpu", "mps", "cuda"):
153
+ raise UserError(f"Unknown device: {device}", "Use auto, cpu, mps or cuda.")
154
+ docs = collect_docs(pdfs)
155
+ out.mkdir(parents=True, exist_ok=True)
156
+
157
+ qs = []
158
+ if questions:
159
+ qs = load_questions(questions, docs)
160
+ elif gen_questions:
161
+ from .llm import LLM
162
+ from .render import page_text
163
+
164
+ llm = LLM(provider=provider, model=model, max_cost=max_cost)
165
+ console.print(f"Drafting questions with {provider}:{llm.model}")
166
+ texts = {d["id"]: "\n\n".join(page_text(Path(d["path"]))) for d in docs}
167
+ qs, _, budget_err = generate_questions(llm, docs, texts, per_doc=per_doc, log=console.print)
168
+ qpath = out / "questions.generated.yaml"
169
+ dump_questions(
170
+ qs, docs, qpath, header=f"# Drafted by {provider}:{llm.model}. Review before trusting."
171
+ )
172
+ console.print(f"Saved {len(qs)} questions to {qpath} (spent ${llm.spent:.4f})")
173
+ if budget_err:
174
+ raise budget_err
175
+ else:
176
+ console.print(
177
+ "[yellow]No --questions or --gen-questions: only the side-by-side viewer and timings.[/]"
178
+ )
179
+
180
+ outputs = {}
181
+ work = out / "work"
182
+ for spec in specs:
183
+ console.rule(f"{spec.label} {spec.version}")
184
+ for hint in missing_tools(spec):
185
+ console.print(f"[yellow]Warning:[/] {hint}")
186
+ if not is_installed(spec):
187
+ console.print(
188
+ f"First use of {spec.label}: installing it in its own environment. "
189
+ "This and the first parse can download several GB."
190
+ )
191
+ t0 = time.perf_counter()
192
+ with console.status(f"Running {spec.label} on {len(docs)} PDF(s)..."):
193
+ outputs[spec.name] = run_parser(
194
+ spec, docs, work, device=device, timeout=timeout, log=console.print
195
+ )
196
+ failed = [d for d, o in outputs[spec.name].items() if not o.ok]
197
+ status = f"[red]{len(failed)} failed[/]" if failed else "[green]ok[/]"
198
+ console.print(f"{spec.label}: {status} in {time.perf_counter() - t0:.0f}s (log: {work / spec.name})")
199
+
200
+ recorded = save_outputs(out / "outputs.json", docs, specs, outputs, device)
201
+ pdf_paths = {d["id"]: Path(d["path"]) for d in docs}
202
+ index = _analyze(recorded, pdf_paths, qs, out, k, no_dense, embed_model, chunk_chars, is_recorded=False)
203
+ _open(index, no_open)
204
+
205
+
206
+ @app.command()
207
+ def demo(
208
+ out: Path = typer.Option(Path("parsearena-demo"), "--out", "-o", help="Where to write the viewer."),
209
+ k: int = typer.Option(5, "--k", help="k for recall@k."),
210
+ no_dense: bool = typer.Option(False, "--no-dense", help="BM25 only, no embeddings download."),
211
+ no_open: bool = typer.Option(False, "--no-open", help="Do not open the viewer."),
212
+ ) -> None:
213
+ """Recorded parser outputs on 3 public PDFs; retrieval metrics computed live."""
214
+ base = Path(str(resources.files("parsearena") / "demo"))
215
+ recorded = json.loads((base / "recorded.json").read_text(encoding="utf-8"))
216
+ m = recorded["meta"]
217
+ created = m["created"].replace("T", " ").replace("Z", " UTC")
218
+ versions = ", ".join(f"{p['label']} {p['version']}" for p in recorded["parsers"])
219
+ console.print(
220
+ f"[bold]Recorded run[/] from {created} (parsearena {m['tool_version']}, {m['hardware']}).\n"
221
+ f"Parsers: {versions}.\nParser outputs are replayed; retrieval is computed now.\n"
222
+ )
223
+ docs = recorded["docs"]
224
+ qs = load_questions(base / "questions.yaml", docs)
225
+ pdf_paths = {d["id"]: base / "pdfs" / d["file"] for d in docs}
226
+ index = _analyze(
227
+ recorded,
228
+ pdf_paths,
229
+ qs,
230
+ out,
231
+ k,
232
+ no_dense,
233
+ DEFAULT_EMBED_MODEL,
234
+ recorded.get("chunk_chars", 1200),
235
+ is_recorded=True,
236
+ )
237
+ _open(index, no_open)
238
+
239
+
240
+ @app.command("parsers")
241
+ def list_parsers() -> None:
242
+ """List supported parsers, pinned versions, licenses and install state."""
243
+ table = Table(header_style="bold")
244
+ for col in ("name", "package", "license", "installed"):
245
+ table.add_column(col)
246
+ for spec in PARSERS.values():
247
+ table.add_row(
248
+ spec.name,
249
+ " ".join(spec.packages),
250
+ spec.license,
251
+ str(env_dir(spec)) if is_installed(spec) else "no",
252
+ )
253
+ console.print(table)
254
+ console.print(f"Environments live in {home() / 'envs'} (set PARSEARENA_HOME to move them).")
255
+
256
+
257
+ @app.command("questions-example")
258
+ def questions_example() -> None:
259
+ """Print an example questions YAML file."""
260
+ console.print(EXAMPLE, markup=False)
261
+
262
+
263
+ def main() -> None:
264
+ try:
265
+ app(standalone_mode=False)
266
+ except UserError as exc:
267
+ err.print(f"[bold red]Error:[/] {exc.message}", markup=True)
268
+ if exc.hint:
269
+ err.print(exc.hint, markup=False)
270
+ sys.exit(1)
271
+ except KeyboardInterrupt:
272
+ err.print("Interrupted.")
273
+ sys.exit(130)
274
+ except typer.Exit as exc:
275
+ sys.exit(exc.exit_code)
276
+ except typer.Abort:
277
+ sys.exit(1)
278
+ except Exception as exc:
279
+ import click
280
+
281
+ if isinstance(exc, click.ClickException):
282
+ exc.show()
283
+ sys.exit(exc.exit_code)
284
+ raise
285
+
286
+
287
+ if __name__ == "__main__":
288
+ main()
@@ -0,0 +1,36 @@
1
+ # Third-party documents
2
+
3
+ The PDFs in this directory are excerpts of third-party documents, bundled only as demo input.
4
+ They are not covered by parsearena's MIT license; each keeps its own terms, listed below.
5
+
6
+ ## census-income-2023.pdf
7
+
8
+ - Title: Income in the United States: 2023 (Current Population Reports, P60-282), September 2024
9
+ - Authors: Gloria Guzman and Melissa Kollar, U.S. Census Bureau
10
+ - Source: https://www.census.gov/library/publications/2024/demo/p60-282.html
11
+ (PDF: https://www2.census.gov/library/publications/2024/demo/p60-282.pdf)
12
+ - License: public domain. Work of the U.S. federal government, not subject to copyright in the
13
+ United States (17 U.S.C. § 105).
14
+ - Changes: only report pages 1, 2 and 15 (PDF pages 7, 8 and 21) are included, unmodified.
15
+
16
+ ## dpo-arxiv-2305.18290.pdf
17
+
18
+ - Title: Direct Preference Optimization: Your Language Model is Secretly a Reward Model (arXiv:2305.18290v3)
19
+ - Authors: Rafael Rafailov, Archit Sharma, Eric Mitchell, Stefano Ermon, Christopher D. Manning, Chelsea Finn
20
+ - Source: https://arxiv.org/abs/2305.18290
21
+ - License: Creative Commons Attribution 4.0 International (CC BY 4.0),
22
+ https://creativecommons.org/licenses/by/4.0/ , as declared on the arXiv abstract page.
23
+ - Attribution: "Direct Preference Optimization: Your Language Model is Secretly a Reward Model" by
24
+ Rafael Rafailov, Archit Sharma, Eric Mitchell, Stefano Ermon, Christopher D. Manning and Chelsea Finn,
25
+ arXiv:2305.18290v3, licensed under CC BY 4.0.
26
+ - Changes: only pages 1, 4, 5 and 9 are included; the pages themselves are unmodified.
27
+
28
+ ## naca-report-1135-scan.pdf
29
+
30
+ - Title: Equations, Tables, and Charts for Compressible Flow (NACA Report 1135), 1953
31
+ - Authors: Ames Research Staff, Ames Aeronautical Laboratory, National Advisory Committee for Aeronautics
32
+ - Source: https://ntrs.nasa.gov/citations/19930091059
33
+ - License: public domain. Work of the U.S. federal government (17 U.S.C. § 105); NASA's Technical
34
+ Reports Server marks it "Public Use Permitted".
35
+ - Changes: only report page 613 (PDF page 2) is included, re-rendered at 200 dpi in grayscale as an
36
+ image-only page so that it has no text layer, like a scan.
@@ -0,0 +1,104 @@
1
+ # Hand-written question set for the bundled demo PDFs.
2
+ # Each answer is a short string copied from the original page.
3
+ questions:
4
+ # U.S. Census Bureau, Income in the United States: 2023 (text page, chart page, table page)
5
+ - id: census-median
6
+ doc: census-income-2023.pdf
7
+ q: What was real median household income in the United States in 2023?
8
+ answers: ["80,610"]
9
+ - id: census-cpi
10
+ doc: census-income-2023.pdf
11
+ q: How much did consumer prices rise between 2022 and 2023 according to the C-CPI-U?
12
+ answers: ["4.0 percent increase"]
13
+ - id: census-earnings-ratio
14
+ doc: census-income-2023.pdf
15
+ q: What was the female-to-male earnings ratio for full-time, year-round workers in 2023?
16
+ answers: ["82.7 percent"]
17
+ - id: census-survey
18
+ doc: census-income-2023.pdf
19
+ q: Which survey supplement are the income estimates based on?
20
+ answers: ["Annual Social and Economic Supplements"]
21
+ - id: census-asian
22
+ doc: census-income-2023.pdf
23
+ q: What was the median income of Asian households in 2023?
24
+ answers: ["112,800"]
25
+ - id: census-bachelor
26
+ doc: census-income-2023.pdf
27
+ q: What was the median household income for householders with a bachelor's degree or higher in 2023?
28
+ answers: ["126,800"]
29
+ - id: census-midwest
30
+ doc: census-income-2023.pdf
31
+ q: What was the median household income in the Midwest in 2023?
32
+ answers: ["81,020"]
33
+ - id: census-nonmetro
34
+ doc: census-income-2023.pdf
35
+ q: What was the median income of households outside metropolitan statistical areas in 2023?
36
+ answers: ["62,520"]
37
+ - id: census-households
38
+ doc: census-income-2023.pdf
39
+ q: How many households (in thousands) were there in 2023?
40
+ answers: ["132,200"]
41
+ - id: census-foreign
42
+ doc: census-income-2023.pdf
43
+ q: What was the 2023 median income of households with a foreign-born householder?
44
+ answers: ["73,360"]
45
+
46
+ # Rafailov et al., Direct Preference Optimization (arXiv:2305.18290, CC BY 4.0)
47
+ - id: dpo-name
48
+ doc: dpo-arxiv-2305.18290.pdf
49
+ q: What does the acronym DPO stand for?
50
+ answers: ["Direct Preference Optimization"]
51
+ - id: dpo-preference-model
52
+ doc: dpo-arxiv-2305.18290.pdf
53
+ q: Under which preference model does the partition function cancel out in the DPO derivation?
54
+ answers: ["Bradley-Terry"]
55
+ - id: dpo-z
56
+ doc: dpo-arxiv-2305.18290.pdf
57
+ q: What is Z(x) called in the closed-form optimal policy?
58
+ answers: ["partition function"]
59
+ - id: dpo-gradient-weight
60
+ doc: dpo-arxiv-2305.18290.pdf
61
+ q: In the gradient of the DPO loss, when does an example get a higher weight?
62
+ answers: ["when reward estimate is wrong"]
63
+ - id: dpo-tldr
64
+ doc: dpo-arxiv-2305.18290.pdf
65
+ q: What win rate does DPO reach on TL;DR summarization at temperature 0.0?
66
+ answers: ["61%"]
67
+ - id: dpo-cnn
68
+ doc: dpo-arxiv-2305.18290.pdf
69
+ q: What GPT-4 win rate against ground truth does DPO get on CNN/DailyMail at temperature 0?
70
+ answers: ["0.36"]
71
+ - id: dpo-dialogue-model
72
+ doc: dpo-arxiv-2305.18290.pdf
73
+ q: Which pre-trained model is the starting point for the single-turn dialogue experiments?
74
+ answers: ["Pythia-2.8B"]
75
+ - id: dpo-venue
76
+ doc: dpo-arxiv-2305.18290.pdf
77
+ q: At which conference was the DPO paper published?
78
+ answers: ["NeurIPS 2023"]
79
+
80
+ # NACA Report 1135 (1953), a scanned page with no text layer (public domain)
81
+ - id: naca-report
82
+ doc: naca-report-1135-scan.pdf
83
+ q: What is the report number of "Equations, Tables, and Charts for Compressible Flow"?
84
+ answers: ["Report 1135"]
85
+ - id: naca-tn
86
+ doc: naca-report-1135-scan.pdf
87
+ q: Which NACA technical note does the compressible flow report revise and extend?
88
+ answers: ["1428"]
89
+ - id: naca-authors
90
+ doc: naca-report-1135-scan.pdf
91
+ q: Who is credited as the author of the compressible flow report?
92
+ answers: ["Ames Research Staff"]
93
+ - id: naca-gamma
94
+ doc: naca-report-1135-scan.pdf
95
+ q: What quantity does the symbol gamma stand for in the compressible flow notation?
96
+ answers: ["ratio of specific heats"]
97
+ - id: naca-extensions
98
+ doc: naca-report-1135-scan.pdf
99
+ q: In which direction were the compressible flow tables extended, according to the introduction?
100
+ answers: ["higher Mach numbers"]
101
+ - id: naca-vm
102
+ doc: naca-report-1135-scan.pdf
103
+ q: What does V_m denote in the list of symbols?
104
+ answers: ["maximum speed obtainable"]