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/errors.py ADDED
@@ -0,0 +1,14 @@
1
+ """Errors that are shown to the user as a short message, never as a traceback."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class UserError(Exception):
7
+ def __init__(self, message: str, hint: str = "") -> None:
8
+ super().__init__(message)
9
+ self.message = message
10
+ self.hint = hint
11
+
12
+
13
+ class BudgetExceeded(UserError):
14
+ pass
parsearena/evaluate.py ADDED
@@ -0,0 +1,130 @@
1
+ """Turn parser outputs plus a question set into retrieval metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from .retrieval import BM25, Embedder, rrf, score_ranks
8
+ from .text import chunk_text, contains, normalize
9
+
10
+ MRR_CUTOFF = 10
11
+
12
+
13
+ @dataclass
14
+ class Question:
15
+ id: str
16
+ doc: str
17
+ q: str
18
+ answers: list[str]
19
+
20
+
21
+ @dataclass
22
+ class DocOutput:
23
+ pages: list[str]
24
+ seconds: float = 0.0
25
+ error: str | None = None
26
+ device: str = ""
27
+ warmup_seconds: float | None = None
28
+ extra: dict = field(default_factory=dict)
29
+
30
+ @property
31
+ def text(self) -> str:
32
+ return "\n\n".join(self.pages)
33
+
34
+ @property
35
+ def ok(self) -> bool:
36
+ return self.error is None
37
+
38
+
39
+ def _first_hit(ranks: dict[int, int], relevant: set[int]) -> int | None:
40
+ hits = [ranks[i] for i in relevant if i in ranks]
41
+ return min(hits) if hits else None
42
+
43
+
44
+ def evaluate(
45
+ outputs: dict[str, dict[str, DocOutput]],
46
+ questions: list[Question],
47
+ k: int = 5,
48
+ embedder: Embedder | None = None,
49
+ chunk_chars: int = 1200,
50
+ progress=None,
51
+ ) -> dict:
52
+ """Score every parser on every question.
53
+
54
+ Retrieval runs over the chunks of *all* documents produced by one parser, so
55
+ a question can be answered from the wrong document. A chunk is relevant when
56
+ it belongs to the question's document and contains one of the answers.
57
+ """
58
+ methods = ["bm25"] + (["dense", "hybrid"] if embedder is not None else [])
59
+ q_vecs = embedder.embed_queries([q.q for q in questions]) if (embedder and questions) else None
60
+ result: dict[str, dict] = {}
61
+ for parser, docs in outputs.items():
62
+ if progress:
63
+ progress(parser)
64
+ chunks = []
65
+ for doc_id, out in docs.items():
66
+ if out.ok:
67
+ chunks.extend(chunk_text(doc_id, out.text, chunk_chars))
68
+ texts = [c.text for c in chunks]
69
+ norm_chunks = [normalize(t) for t in texts]
70
+ norm_docs = {d: normalize(o.text) for d, o in docs.items() if o.ok}
71
+ bm25 = BM25(texts) if texts else None
72
+ c_vecs = embedder.embed(texts) if (embedder and texts) else None
73
+ per_q = []
74
+ for qi, q in enumerate(questions):
75
+ survives = any(contains(norm_docs.get(q.doc, ""), a) for a in q.answers)
76
+ relevant = {
77
+ i
78
+ for i, c in enumerate(chunks)
79
+ if c.doc == q.doc and any(contains(norm_chunks[i], a) for a in q.answers)
80
+ }
81
+ ranks: dict[str, int | None] = {}
82
+ if texts:
83
+ orders = {"bm25": score_ranks(bm25.scores(q.q), drop_zero=True)}
84
+ if c_vecs is not None and q_vecs is not None:
85
+ orders["dense"] = score_ranks((c_vecs @ q_vecs[qi]).tolist())
86
+ fused = rrf([orders["bm25"], orders["dense"]])
87
+ orders["hybrid"] = score_ranks([fused.get(i, 0.0) for i in range(len(texts))])
88
+ for m in methods:
89
+ ranks[m] = _first_hit(orders[m], relevant)
90
+ else:
91
+ ranks = {m: None for m in methods}
92
+ per_q.append({"id": q.id, "doc": q.doc, "survives": survives, "ranks": ranks})
93
+ summary = {"survives": _mean([p["survives"] for p in per_q]), "chunks": len(chunks)}
94
+ for m in methods:
95
+ rs = [p["ranks"][m] for p in per_q]
96
+ summary[m] = {
97
+ "recall": _mean([r is not None and r <= k for r in rs]),
98
+ "mrr": _mean([(1.0 / r) if (r is not None and r <= MRR_CUTOFF) else 0.0 for r in rs]),
99
+ }
100
+ result[parser] = {"summary": summary, "questions": per_q}
101
+ return {
102
+ "k": k,
103
+ "mrr_cutoff": MRR_CUTOFF,
104
+ "methods": methods,
105
+ "chunk_chars": chunk_chars,
106
+ "parsers": result,
107
+ }
108
+
109
+
110
+ def timings(outputs: dict[str, dict[str, DocOutput]], pages: dict[str, int]) -> dict[str, dict]:
111
+ out = {}
112
+ for parser, docs in outputs.items():
113
+ ok = [(d, o) for d, o in docs.items() if o.ok]
114
+ n_pages = sum(pages.get(d, len(o.pages)) for d, o in ok)
115
+ secs = sum(o.seconds for _, o in ok)
116
+ warm = next((o.warmup_seconds for o in docs.values() if o.warmup_seconds is not None), None)
117
+ out[parser] = {
118
+ "pages": n_pages,
119
+ "seconds": secs,
120
+ "seconds_per_page": (secs / n_pages) if n_pages else None,
121
+ "warmup_seconds": warm,
122
+ "failed": [{"doc": d, "error": o.error} for d, o in docs.items() if not o.ok],
123
+ "device": next((o.device for o in docs.values() if o.device), ""),
124
+ }
125
+ return out
126
+
127
+
128
+ def _mean(values) -> float:
129
+ values = list(values)
130
+ return (sum(float(v) for v in values) / len(values)) if values else 0.0
parsearena/llm.py ADDED
@@ -0,0 +1,154 @@
1
+ """One OpenAI-compatible client for every provider, with a hard spending cap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from dataclasses import dataclass, field
8
+
9
+ from .errors import BudgetExceeded, UserError
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Preset:
14
+ base_url: str
15
+ key_env: str | None
16
+ model: str
17
+ price_in: float # USD per million input tokens (estimate used for the pre-call check)
18
+ price_out: float # USD per million output tokens
19
+
20
+
21
+ PRESETS: dict[str, Preset] = {
22
+ "ollama": Preset("http://localhost:11434/v1", None, "qwen3:4b", 0.0, 0.0),
23
+ "openrouter": Preset(
24
+ "https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", "deepseek/deepseek-v4.1-flash", 0.045, 0.50
25
+ ),
26
+ "openai": Preset("https://api.openai.com/v1", "OPENAI_API_KEY", "gpt-5.6-luna", 0.20, 1.20),
27
+ "mistral": Preset("https://api.mistral.ai/v1", "MISTRAL_API_KEY", "ministral-8b-latest", 0.10, 0.10),
28
+ }
29
+
30
+
31
+ def ollama_base_url() -> str:
32
+ host = os.environ.get("OLLAMA_HOST", "").strip()
33
+ if not host:
34
+ return PRESETS["ollama"].base_url
35
+ if not host.startswith(("http://", "https://")):
36
+ host = f"http://{host}"
37
+ return host.rstrip("/") + "/v1"
38
+
39
+
40
+ @dataclass
41
+ class LLM:
42
+ provider: str
43
+ model: str
44
+ max_cost: float = 1.0
45
+ price_in: float | None = None
46
+ price_out: float | None = None
47
+ spent: float = 0.0
48
+ calls: int = 0
49
+ _client: object = field(default=None, repr=False)
50
+
51
+ def __post_init__(self) -> None:
52
+ if self.provider not in PRESETS:
53
+ raise UserError(f"Unknown provider: {self.provider}", f"Choose one of: {', '.join(PRESETS)}")
54
+ preset = PRESETS[self.provider]
55
+ self.model = self.model or preset.model
56
+ if self.price_in is None:
57
+ self.price_in = preset.price_in
58
+ if self.price_out is None:
59
+ self.price_out = preset.price_out
60
+ if preset.key_env:
61
+ key = os.environ.get(preset.key_env, "").strip()
62
+ if not key:
63
+ raise UserError(
64
+ f"Missing API key: set {preset.key_env}.",
65
+ f"Example: export {preset.key_env}=... or use --provider ollama for a free local model.",
66
+ )
67
+ base_url = preset.base_url
68
+ else:
69
+ key, base_url = "ollama", ollama_base_url()
70
+ from openai import OpenAI
71
+
72
+ self.base_url = base_url
73
+ self.timeout = float(os.environ.get("PARSEARENA_LLM_TIMEOUT", "300"))
74
+ self._client = OpenAI(base_url=base_url, api_key=key, max_retries=0, timeout=self.timeout)
75
+
76
+ @property
77
+ def local(self) -> bool:
78
+ return self.provider == "ollama"
79
+
80
+ def estimate(self, prompt: str, max_tokens: int) -> float:
81
+ tokens_in = len(prompt) / 3.5
82
+ return (tokens_in * self.price_in + max_tokens * self.price_out) / 1e6
83
+
84
+ def complete(self, system: str, prompt: str, max_tokens: int = 2000) -> str:
85
+ if not self.local:
86
+ projected = self.spent + self.estimate(system + prompt, max_tokens)
87
+ if projected > self.max_cost:
88
+ raise BudgetExceeded(
89
+ f"Budget exceeded: the next call could bring spend to ${projected:.4f}, "
90
+ f"over --max-cost ${self.max_cost:.2f}.",
91
+ "Partial results were kept. Raise --max-cost or use --provider ollama.",
92
+ )
93
+ extra = {"usage": {"include": True}} if self.provider == "openrouter" else None
94
+ resp = self._call(system, prompt, max_tokens, extra)
95
+ self.calls += 1
96
+ self.spent += self._cost(resp)
97
+ return (resp.choices[0].message.content or "").strip()
98
+
99
+ def _call(self, system, prompt, max_tokens, extra):
100
+ import openai
101
+
102
+ delay = 2.0
103
+ for attempt in range(5):
104
+ try:
105
+ return self._client.chat.completions.create(
106
+ model=self.model,
107
+ messages=[{"role": "system", "content": system}, {"role": "user", "content": prompt}],
108
+ max_tokens=max_tokens,
109
+ temperature=0.2,
110
+ extra_body=extra,
111
+ )
112
+ except openai.APITimeoutError as exc:
113
+ if self.local or attempt == 4:
114
+ raise UserError(
115
+ f"{self.provider} did not answer within {self.timeout:.0f}s.",
116
+ "The server may be busy or still loading the model. Try again, "
117
+ "or raise the limit with PARSEARENA_LLM_TIMEOUT (seconds).",
118
+ ) from exc
119
+ except openai.APIConnectionError as exc:
120
+ if self.local:
121
+ raise UserError(
122
+ f"Ollama is not reachable at {self.base_url}.",
123
+ "Start it with `ollama serve`, or set OLLAMA_HOST.",
124
+ ) from exc
125
+ if attempt == 4:
126
+ raise UserError(f"Could not reach {self.provider}: {exc}") from exc
127
+ except openai.AuthenticationError as exc:
128
+ raise UserError(
129
+ f"{self.provider} rejected the API key.", f"Check {PRESETS[self.provider].key_env}."
130
+ ) from exc
131
+ except openai.NotFoundError as exc:
132
+ hint = f"Pull it with `ollama pull {self.model}`." if self.local else "Check --model."
133
+ raise UserError(f"Model not found on {self.provider}: {self.model}", hint) from exc
134
+ except (openai.RateLimitError, openai.InternalServerError) as exc:
135
+ if attempt == 4:
136
+ raise UserError(f"{self.provider} keeps failing: {exc}", "Try again later.") from exc
137
+ time.sleep(delay)
138
+ delay *= 2
139
+ raise UserError(f"{self.provider} did not answer.")
140
+
141
+ def _cost(self, resp) -> float:
142
+ if self.local:
143
+ return 0.0
144
+ usage = getattr(resp, "usage", None)
145
+ if usage is None:
146
+ return 0.0
147
+ reported = getattr(usage, "cost", None)
148
+ if reported is None and getattr(usage, "model_extra", None):
149
+ reported = usage.model_extra.get("cost")
150
+ if reported is not None:
151
+ return float(reported)
152
+ return (
153
+ (usage.prompt_tokens or 0) * self.price_in + (usage.completion_tokens or 0) * self.price_out
154
+ ) / 1e6
parsearena/parsers.py ADDED
@@ -0,0 +1,197 @@
1
+ """Parser registry and isolated environments.
2
+
3
+ Every parser lives in its own uv-managed virtual environment under
4
+ ``$PARSEARENA_HOME/envs`` and is driven through a small runner script in a
5
+ subprocess. parsearena never imports a parser and never bundles one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from importlib import resources
18
+ from pathlib import Path
19
+
20
+ from .errors import UserError
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ParserSpec:
25
+ name: str
26
+ label: str
27
+ packages: tuple[str, ...]
28
+ runner: str
29
+ license: str
30
+ url: str
31
+ python: str = "3.12"
32
+ env: tuple[tuple[str, str], ...] = ()
33
+ # (binary, env var that may point to it, install hint) checked before running
34
+ tools: tuple[tuple[str, str, str], ...] = ()
35
+ note: str = ""
36
+
37
+ @property
38
+ def version(self) -> str:
39
+ return self.packages[0].split("==", 1)[1]
40
+
41
+ @property
42
+ def key(self) -> str:
43
+ digest = hashlib.sha256(json.dumps([self.packages, self.python]).encode()).hexdigest()[:8]
44
+ return f"{self.name}-{self.version}-py{self.python.replace('.', '')}-{digest}"
45
+
46
+
47
+ PARSERS: dict[str, ParserSpec] = {
48
+ "docling": ParserSpec(
49
+ name="docling",
50
+ label="Docling",
51
+ packages=("docling==2.146.0",),
52
+ runner="run_docling.py",
53
+ license="MIT",
54
+ url="https://github.com/docling-project/docling",
55
+ ),
56
+ "marker": ParserSpec(
57
+ name="marker",
58
+ label="Marker",
59
+ packages=("marker-pdf==2.1.0",),
60
+ runner="run_marker.py",
61
+ license="Code Apache-2.0; model weights modified OpenRAIL-M",
62
+ url="https://github.com/datalab-to/marker",
63
+ tools=(
64
+ (
65
+ "llama-server",
66
+ "LLAMA_CPP_BINARY",
67
+ "Marker 2.x runs its OCR model through llama.cpp when there is no NVIDIA GPU: "
68
+ "`brew install llama.cpp`, or set LLAMA_CPP_BINARY to a llama-server binary.",
69
+ ),
70
+ ),
71
+ ),
72
+ "mineru": ParserSpec(
73
+ name="mineru",
74
+ label="MinerU",
75
+ packages=("mineru==4.1.6",),
76
+ runner="run_mineru.py",
77
+ license="Apache-2.0 with additional terms",
78
+ url="https://github.com/opendatalab/MinerU",
79
+ env=(("MINERU_MODEL_SOURCE", "huggingface"), ("MINERU_TELEMETRY_DISABLED", "1")),
80
+ note='effort "medium": local layout, OCR, table and formula models, no VLM',
81
+ ),
82
+ "unstructured": ParserSpec(
83
+ name="unstructured",
84
+ label="Unstructured",
85
+ packages=("unstructured[pdf]==0.27.36",),
86
+ runner="run_unstructured.py",
87
+ license="Apache-2.0",
88
+ url="https://github.com/Unstructured-IO/unstructured",
89
+ tools=(
90
+ (
91
+ "pdftoppm",
92
+ "",
93
+ "Unstructured hi_res needs poppler: `brew install poppler` / `apt install poppler-utils`.",
94
+ ),
95
+ (
96
+ "tesseract",
97
+ "",
98
+ "Unstructured hi_res needs tesseract: "
99
+ "`brew install tesseract` / `apt install tesseract-ocr`.",
100
+ ),
101
+ ),
102
+ ),
103
+ "pypdf": ParserSpec(
104
+ name="pypdf",
105
+ label="pypdf (baseline)",
106
+ packages=("pypdf==6.21.4",),
107
+ runner="run_pypdf.py",
108
+ license="BSD-3-Clause",
109
+ url="https://github.com/py-pdf/pypdf",
110
+ ),
111
+ }
112
+
113
+ DEFAULT_PARSERS = ("docling", "marker", "mineru", "unstructured", "pypdf")
114
+
115
+
116
+ def parse_parser_list(value: str) -> list[ParserSpec]:
117
+ names = [n.strip().lower() for n in value.split(",") if n.strip()]
118
+ if not names:
119
+ raise UserError("No parsers selected.", f"Choose from: {', '.join(PARSERS)}")
120
+ unknown = [n for n in names if n not in PARSERS]
121
+ if unknown:
122
+ raise UserError(
123
+ f"Unknown parser: {', '.join(unknown)}",
124
+ f"Available parsers: {', '.join(PARSERS)}",
125
+ )
126
+ return [PARSERS[n] for n in dict.fromkeys(names)]
127
+
128
+
129
+ def home() -> Path:
130
+ if os.environ.get("PARSEARENA_HOME"):
131
+ return Path(os.environ["PARSEARENA_HOME"]).expanduser()
132
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
133
+ return Path(base) / "parsearena"
134
+
135
+
136
+ def env_dir(spec: ParserSpec) -> Path:
137
+ return home() / "envs" / spec.key
138
+
139
+
140
+ def env_python(spec: ParserSpec) -> Path:
141
+ d = env_dir(spec)
142
+ return d / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
143
+
144
+
145
+ def missing_tools(spec: ParserSpec) -> list[str]:
146
+ """Install hints for system tools the parser needs but that are not available."""
147
+ hints = []
148
+ for binary, env_var, hint in spec.tools:
149
+ configured = os.environ.get(env_var) if env_var else None
150
+ if configured and Path(configured).exists():
151
+ continue
152
+ if shutil.which(binary) is None:
153
+ hints.append(hint)
154
+ return hints
155
+
156
+
157
+ def find_uv() -> str:
158
+ uv = shutil.which("uv")
159
+ if uv is None:
160
+ raise UserError(
161
+ "uv was not found on PATH.",
162
+ "parsearena installs each parser in its own uv environment. "
163
+ "Install uv: https://docs.astral.sh/uv/getting-started/installation/",
164
+ )
165
+ return uv
166
+
167
+
168
+ def is_installed(spec: ParserSpec) -> bool:
169
+ return (env_dir(spec) / ".parsearena-ok").exists() and env_python(spec).exists()
170
+
171
+
172
+ def ensure_env(spec: ParserSpec, log=print) -> Path:
173
+ """Create the parser's environment if needed and return its python."""
174
+ if is_installed(spec):
175
+ return env_python(spec)
176
+ uv = find_uv()
177
+ d = env_dir(spec)
178
+ d.parent.mkdir(parents=True, exist_ok=True)
179
+ log(f"Installing {spec.label} ({', '.join(spec.packages)}) into {d}")
180
+ steps = [
181
+ [uv, "venv", "--quiet", "--clear", "--python", spec.python, str(d)],
182
+ [uv, "pip", "install", "--quiet", "--python", str(env_python(spec)), *spec.packages],
183
+ ]
184
+ for cmd in steps:
185
+ proc = subprocess.run(cmd, capture_output=True, text=True)
186
+ if proc.returncode != 0:
187
+ tail = (proc.stderr or proc.stdout).strip().splitlines()[-15:]
188
+ raise UserError(
189
+ f"Could not install {spec.label}.",
190
+ "uv said:\n" + "\n".join(tail),
191
+ )
192
+ (d / ".parsearena-ok").write_text(json.dumps({"packages": spec.packages}))
193
+ return env_python(spec)
194
+
195
+
196
+ def runner_dir() -> Path:
197
+ return Path(str(resources.files("parsearena") / "runners"))