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/pipeline.py ADDED
@@ -0,0 +1,276 @@
1
+ """Run parsers over PDFs, one isolated subprocess per parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import re
9
+ import signal
10
+ import subprocess
11
+ import time
12
+ from dataclasses import asdict
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+ import pypdfium2 as pdfium
17
+
18
+ from . import __version__
19
+ from .errors import UserError
20
+ from .evaluate import DocOutput
21
+ from .parsers import ParserSpec, ensure_env, runner_dir
22
+
23
+
24
+ def doc_id_for(path: Path, taken: set[str]) -> str:
25
+ base = re.sub(r"[^A-Za-z0-9._-]+", "-", path.stem).strip("-") or "doc"
26
+ doc_id, n = base, 2
27
+ while doc_id in taken:
28
+ doc_id, n = f"{base}-{n}", n + 1
29
+ taken.add(doc_id)
30
+ return doc_id
31
+
32
+
33
+ def collect_docs(paths: list[Path]) -> list[dict]:
34
+ docs, taken = [], set()
35
+ for p in paths:
36
+ if not p.exists():
37
+ raise UserError(f"File not found: {p}")
38
+ if p.suffix.lower() != ".pdf":
39
+ raise UserError(f"Not a PDF: {p}", "parsearena v0.1 reads PDF files only.")
40
+ try:
41
+ pdf = pdfium.PdfDocument(str(p))
42
+ n = len(pdf)
43
+ pdf.close()
44
+ except pdfium.PdfiumError as exc:
45
+ raise UserError(f"Could not open {p}: {exc}") from exc
46
+ docs.append({"id": doc_id_for(p, taken), "file": p.name, "path": str(p.resolve()), "num_pages": n})
47
+ if not docs:
48
+ raise UserError("No PDFs given.", "Example: parsearena run docs/*.pdf")
49
+ return docs
50
+
51
+
52
+ def hardware() -> str:
53
+ machine = platform.machine()
54
+ cpu = platform.processor() or machine
55
+ if platform.system() == "Darwin":
56
+ try:
57
+ cpu = (
58
+ subprocess.run(
59
+ ["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True, timeout=5
60
+ ).stdout.strip()
61
+ or cpu
62
+ )
63
+ mem = int(
64
+ subprocess.run(
65
+ ["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5
66
+ ).stdout
67
+ )
68
+ return f"{cpu}, {round(mem / 2**30)} GB RAM, macOS {platform.mac_ver()[0]}"
69
+ except (OSError, ValueError, subprocess.SubprocessError):
70
+ pass
71
+ mem = ""
72
+ try:
73
+ mem = f", {round(os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES') / 2**30)} GB RAM"
74
+ except (ValueError, OSError, AttributeError):
75
+ pass
76
+ return f"{cpu}{mem}, {platform.system()} {platform.release()}"
77
+
78
+
79
+ def make_warmup_pdf(src: Path, dst: Path) -> dict:
80
+ """Two pages: the first page of ``src`` as is, and the same page as an image only."""
81
+ source = pdfium.PdfDocument(str(src))
82
+ out = pdfium.PdfDocument.new()
83
+ try:
84
+ out.import_pages(source, [0])
85
+ page = source[0]
86
+ w, h = page.get_size()
87
+ bitmap = page.render(scale=150 / 72)
88
+ image = pdfium.PdfImage.new(out)
89
+ image.set_bitmap(bitmap)
90
+ image.set_matrix(pdfium.PdfMatrix().scale(w, h))
91
+ new = out.new_page(w, h)
92
+ new.insert_obj(image)
93
+ new.gen_content()
94
+ out.save(str(dst))
95
+ finally:
96
+ source.close()
97
+ out.close()
98
+ return {"path": str(dst), "num_pages": 2}
99
+
100
+
101
+ def run_parser(
102
+ spec: ParserSpec,
103
+ docs: list[dict],
104
+ work: Path,
105
+ device: str = "auto",
106
+ timeout: int = 3600,
107
+ warmup: bool = True,
108
+ log=print,
109
+ ) -> dict[str, DocOutput]:
110
+ python = ensure_env(spec, log=log)
111
+ # The runner's working directory is pdir, so every path handed to it is absolute.
112
+ pdir = work.resolve() / spec.name
113
+ pdir.mkdir(parents=True, exist_ok=True)
114
+ warmup_doc = make_warmup_pdf(Path(docs[0]["path"]), pdir / "warmup.pdf") if (warmup and docs) else None
115
+ manifest = {
116
+ "device": device,
117
+ "warmup_doc": warmup_doc,
118
+ "docs": [
119
+ {
120
+ "id": d["id"],
121
+ "path": d["path"],
122
+ "num_pages": d["num_pages"],
123
+ "out": str(pdir / f"{d['id']}.json"),
124
+ }
125
+ for d in docs
126
+ ],
127
+ }
128
+ for d in manifest["docs"]:
129
+ Path(d["out"]).unlink(missing_ok=True)
130
+ mpath = pdir / "manifest.json"
131
+ mpath.write_text(json.dumps(manifest, indent=2))
132
+ env = {**dict(spec.env), **os.environ, "PYTHONUNBUFFERED": "1", "TOKENIZERS_PARALLELISM": "false"}
133
+ env.pop("PYTHONPATH", None)
134
+ if device == "cpu":
135
+ env["CUDA_VISIBLE_DEVICES"] = ""
136
+ if device != "auto":
137
+ env["TORCH_DEVICE"] = device
138
+ log_path = pdir / "stderr.log"
139
+ started = time.perf_counter()
140
+ error = None
141
+ with open(log_path, "w") as lf:
142
+ proc = subprocess.Popen(
143
+ [str(python), str(runner_dir() / spec.runner), str(mpath)],
144
+ stdout=lf,
145
+ stderr=subprocess.STDOUT,
146
+ env=env,
147
+ cwd=pdir,
148
+ start_new_session=True,
149
+ )
150
+ try:
151
+ code = proc.wait(timeout=timeout)
152
+ if code != 0:
153
+ error = f"runner exited with code {code}"
154
+ except subprocess.TimeoutExpired:
155
+ error = f"timed out after {timeout}s"
156
+ finally:
157
+ stop_process_tree(proc)
158
+ wall = time.perf_counter() - started
159
+ results: dict[str, DocOutput] = {}
160
+ for d in manifest["docs"]:
161
+ out = Path(d["out"])
162
+ if out.exists():
163
+ raw = json.loads(out.read_text())
164
+ results[d["id"]] = DocOutput(
165
+ pages=raw.get("pages", []),
166
+ seconds=raw.get("seconds", 0.0),
167
+ error=raw.get("error"),
168
+ device=raw.get("device", ""),
169
+ warmup_seconds=raw.get("warmup_seconds"),
170
+ extra={"version": raw.get("version"), "wall_seconds": wall},
171
+ )
172
+ else:
173
+ tail = _tail(log_path)
174
+ results[d["id"]] = DocOutput(
175
+ pages=[], error=f"{error or 'no output'}: {tail}"[:1500], extra={"wall_seconds": wall}
176
+ )
177
+ return results
178
+
179
+
180
+ def _descendants(pid: int) -> list[int]:
181
+ try:
182
+ out = subprocess.run(
183
+ ["ps", "-A", "-o", "pid=,ppid="], capture_output=True, text=True, timeout=10
184
+ ).stdout
185
+ except (OSError, subprocess.SubprocessError):
186
+ return []
187
+ children: dict[int, list[int]] = {}
188
+ for line in out.splitlines():
189
+ parts = line.split()
190
+ if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
191
+ children.setdefault(int(parts[1]), []).append(int(parts[0]))
192
+ found, stack = [], [pid]
193
+ while stack:
194
+ for child in children.get(stack.pop(), []):
195
+ found.append(child)
196
+ stack.append(child)
197
+ return found
198
+
199
+
200
+ def stop_process_tree(proc: subprocess.Popen, grace: float = 5.0) -> None:
201
+ """Make sure neither the runner nor anything it started outlives it.
202
+
203
+ The runner kills its own helpers on a normal exit; this covers timeouts and
204
+ crashes: every descendant (even one in its own session) and the runner's
205
+ process group get SIGTERM, then SIGKILL.
206
+ """
207
+ running = proc.poll() is None
208
+ # Only signal PIDs we know are ours: after a normal exit the runner is reaped and
209
+ # its PID may be reused, so only its process group is signalled.
210
+ targets = [*_descendants(proc.pid), proc.pid] if running else []
211
+ for sig in (signal.SIGTERM, signal.SIGKILL):
212
+ for p in targets:
213
+ try:
214
+ os.kill(p, sig)
215
+ except (ProcessLookupError, PermissionError):
216
+ pass
217
+ try:
218
+ os.killpg(proc.pid, sig)
219
+ except (ProcessLookupError, PermissionError):
220
+ pass
221
+ try:
222
+ proc.wait(timeout=grace)
223
+ except subprocess.TimeoutExpired:
224
+ continue
225
+ if sig == signal.SIGTERM:
226
+ time.sleep(0.2)
227
+ targets = [p for p in targets if _pid_alive(p)]
228
+ if not targets:
229
+ break
230
+
231
+
232
+ def _pid_alive(pid: int) -> bool:
233
+ try:
234
+ os.kill(pid, 0)
235
+ return True
236
+ except (ProcessLookupError, PermissionError):
237
+ return False
238
+
239
+
240
+ def _tail(path: Path, lines: int = 6) -> str:
241
+ try:
242
+ text = path.read_text(errors="replace").strip().splitlines()
243
+ except OSError:
244
+ return ""
245
+ return " | ".join(text[-lines:])
246
+
247
+
248
+ def save_outputs(path: Path, docs: list[dict], specs: list[ParserSpec], outputs, device: str) -> dict:
249
+ data = {
250
+ "meta": {
251
+ "tool": "parsearena",
252
+ "tool_version": __version__,
253
+ "created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
254
+ "hardware": hardware(),
255
+ "device_requested": device,
256
+ },
257
+ "parsers": [
258
+ {
259
+ "name": s.name,
260
+ "label": s.label,
261
+ "version": s.version,
262
+ "packages": list(s.packages),
263
+ "license": s.license,
264
+ "url": s.url,
265
+ }
266
+ for s in specs
267
+ ],
268
+ "docs": [{k: v for k, v in d.items() if k != "path"} for d in docs],
269
+ "outputs": {p: {d: asdict(o) for d, o in per.items()} for p, per in outputs.items()},
270
+ }
271
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=1))
272
+ return data
273
+
274
+
275
+ def load_outputs(data: dict) -> dict[str, dict[str, DocOutput]]:
276
+ return {p: {d: DocOutput(**o) for d, o in per.items()} for p, per in data["outputs"].items()}
@@ -0,0 +1,125 @@
1
+ """Question sets: hand-written YAML or drafted by an LLM from the PDF text layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from .errors import BudgetExceeded, UserError
12
+ from .evaluate import Question
13
+ from .text import contains, normalize
14
+
15
+ EXAMPLE = """questions:
16
+ - doc: report.pdf # file name (or its stem) of one of the PDFs
17
+ q: What was the median household income in 2023?
18
+ answer: "80,610" # a short string that must appear in the document
19
+ - doc: report.pdf
20
+ q: Which region had the highest median income?
21
+ answers: ["West", "the West"] # any of these counts
22
+ """
23
+
24
+
25
+ def load_questions(path: Path, docs: list[dict]) -> list[Question]:
26
+ if not path.exists():
27
+ raise UserError(f"Questions file not found: {path}", "Format:\n" + EXAMPLE)
28
+ try:
29
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
30
+ except yaml.YAMLError as exc:
31
+ raise UserError(f"Could not parse {path}: {exc}", "Format:\n" + EXAMPLE) from exc
32
+ items = raw.get("questions") if isinstance(raw, dict) else raw
33
+ if not isinstance(items, list):
34
+ raise UserError(f"{path} has no `questions:` list.", "Format:\n" + EXAMPLE)
35
+ by_name = {}
36
+ for d in docs:
37
+ by_name[d["id"]] = d["id"]
38
+ by_name[d["file"]] = d["id"]
39
+ by_name[Path(d["file"]).stem] = d["id"]
40
+ out: list[Question] = []
41
+ for i, item in enumerate(items, start=1):
42
+ if not isinstance(item, dict):
43
+ raise UserError(f"Question {i} in {path} is not a mapping.", "Format:\n" + EXAMPLE)
44
+ doc = str(item.get("doc", ""))
45
+ if doc not in by_name:
46
+ raise UserError(
47
+ f"Question {i} refers to unknown doc {doc!r}.",
48
+ "Use the file name of one of the PDFs passed to run: " + ", ".join(d["file"] for d in docs),
49
+ )
50
+ answers = item.get("answers") or ([item["answer"]] if "answer" in item else [])
51
+ answers = [str(a) for a in answers if str(a).strip()]
52
+ if not item.get("q") or not answers:
53
+ raise UserError(f"Question {i} in {path} needs `q` and `answer` (or `answers`).")
54
+ out.append(
55
+ Question(id=str(item.get("id") or f"q{i}"), doc=by_name[doc], q=str(item["q"]), answers=answers)
56
+ )
57
+ return out
58
+
59
+
60
+ def dump_questions(questions: list[Question], docs: list[dict], path: Path, header: str = "") -> None:
61
+ file_of = {d["id"]: d["file"] for d in docs}
62
+ data = {
63
+ "questions": [
64
+ {"id": q.id, "doc": file_of.get(q.doc, q.doc), "q": q.q, "answers": q.answers} for q in questions
65
+ ]
66
+ }
67
+ text = yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=100)
68
+ path.write_text((header + "\n" if header else "") + text, encoding="utf-8")
69
+
70
+
71
+ SYSTEM = (
72
+ "You write retrieval test questions for a document. Each question must be answerable from a "
73
+ "single passage, and its answer must be a SHORT string (a number, a name, a few words) copied "
74
+ "verbatim from the text. Reply with JSON only."
75
+ )
76
+
77
+ PROMPT = """Text of the document "{name}" (may be truncated):
78
+ <<<
79
+ {text}
80
+ >>>
81
+ Write {n} questions about specific facts in this text (numbers in tables, definitions, names).
82
+ Return a JSON list like [{{"q": "...", "answer": "..."}}].
83
+ The answer must appear verbatim in the text above."""
84
+
85
+
86
+ def _parse_json_list(reply: str) -> list[dict]:
87
+ reply = re.sub(r"<think>.*?</think>", "", reply, flags=re.S)
88
+ m = re.search(r"\[.*\]", reply, flags=re.S)
89
+ if not m:
90
+ return []
91
+ try:
92
+ data = json.loads(m.group(0))
93
+ except json.JSONDecodeError:
94
+ return []
95
+ return [d for d in data if isinstance(d, dict)]
96
+
97
+
98
+ def generate_questions(llm, docs: list[dict], texts: dict[str, str], per_doc: int = 5, log=print):
99
+ """Draft questions per document; keep only those whose answer is in the source text.
100
+
101
+ Returns (questions, skipped_docs, budget_error). Stops early, keeping what it
102
+ has, if the next call would exceed the budget.
103
+ """
104
+ questions: list[Question] = []
105
+ skipped: list[str] = []
106
+ for d in docs:
107
+ text = texts.get(d["id"], "")
108
+ if len(text.strip()) < 200:
109
+ skipped.append(d["file"])
110
+ log(f"Skipping {d['file']}: no usable text layer to draft questions from (scanned?).")
111
+ continue
112
+ prompt = PROMPT.format(name=d["file"], text=text[:12000], n=per_doc)
113
+ try:
114
+ reply = llm.complete(SYSTEM, prompt, max_tokens=4000)
115
+ except BudgetExceeded as exc:
116
+ return questions, skipped, exc
117
+ norm = normalize(text)
118
+ kept = 0
119
+ for item in _parse_json_list(reply):
120
+ q, a = str(item.get("q", "")).strip(), str(item.get("answer", "")).strip()
121
+ if q and a and len(a) <= 80 and contains(norm, a):
122
+ kept += 1
123
+ questions.append(Question(id=f"{d['id']}-g{kept}", doc=d["id"], q=q, answers=[a]))
124
+ log(f"{d['file']}: kept {kept} questions")
125
+ return questions, skipped, None
parsearena/render.py ADDED
@@ -0,0 +1,40 @@
1
+ """Render PDF pages to images for the viewer (pypdfium2, no external tools)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pypdfium2 as pdfium
8
+
9
+
10
+ def render_pages(pdf_path: Path, out_dir: Path, width: int = 900) -> list[str]:
11
+ """Write one WebP per page and return their file names (relative to out_dir)."""
12
+ out_dir.mkdir(parents=True, exist_ok=True)
13
+ names = []
14
+ pdf = pdfium.PdfDocument(str(pdf_path))
15
+ try:
16
+ for i in range(len(pdf)):
17
+ page = pdf[i]
18
+ scale = width / max(page.get_width(), 1)
19
+ image = page.render(scale=scale).to_pil().convert("RGB")
20
+ name = f"{i + 1:03d}.webp"
21
+ image.save(out_dir / name, "WEBP", quality=80, method=4)
22
+ names.append(name)
23
+ page.close()
24
+ finally:
25
+ pdf.close()
26
+ return names
27
+
28
+
29
+ def page_text(pdf_path: Path) -> list[str]:
30
+ """Text layer of every page (used only to draft questions)."""
31
+ pdf = pdfium.PdfDocument(str(pdf_path))
32
+ try:
33
+ out = []
34
+ for i in range(len(pdf)):
35
+ tp = pdf[i].get_textpage()
36
+ out.append(tp.get_text_range())
37
+ tp.close()
38
+ return out
39
+ finally:
40
+ pdf.close()
parsearena/report.py ADDED
@@ -0,0 +1,57 @@
1
+ """Static HTML viewer: one index.html plus rendered page images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from datetime import UTC, datetime
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+ from .render import render_pages
12
+
13
+
14
+ def build_report(
15
+ out_dir: Path,
16
+ recorded: dict,
17
+ pdf_paths: dict[str, Path],
18
+ questions: list,
19
+ evaluation: dict,
20
+ timings: dict,
21
+ is_recorded: bool,
22
+ doc_info: dict[str, dict] | None = None,
23
+ ) -> Path:
24
+ """Write ``out_dir/index.html`` and ``out_dir/pages/<doc>/NNN.webp``."""
25
+ out_dir.mkdir(parents=True, exist_ok=True)
26
+ pages_root = out_dir / "pages"
27
+ if pages_root.exists():
28
+ shutil.rmtree(pages_root)
29
+ docs = []
30
+ for d in recorded["docs"]:
31
+ names = render_pages(pdf_paths[d["id"]], pages_root / d["id"])
32
+ info = (doc_info or {}).get(d["id"], {})
33
+ docs.append({**d, **info, "images": [f"pages/{d['id']}/{n}" for n in names]})
34
+ evaluation = {**evaluation, "computed": datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")}
35
+ data = {
36
+ "meta": {**recorded["meta"], "recorded": is_recorded},
37
+ "parsers": recorded["parsers"],
38
+ "docs": docs,
39
+ "outputs": {
40
+ p: {d: {"pages": o["pages"], "error": o["error"]} for d, o in per.items()}
41
+ for p, per in recorded["outputs"].items()
42
+ },
43
+ "questions": [{"id": q.id, "doc": q.doc, "q": q.q, "answers": q.answers} for q in questions],
44
+ "evaluation": evaluation,
45
+ "timings": timings,
46
+ }
47
+ payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
48
+ template = (resources.files("parsearena") / "report_template.html").read_text(encoding="utf-8")
49
+ index = out_dir / "index.html"
50
+ index.write_text(template.replace("__DATA__", payload), encoding="utf-8")
51
+ (out_dir / "results.json").write_text(
52
+ json.dumps(
53
+ {k: data[k] for k in ("meta", "parsers", "evaluation", "timings")}, indent=1, ensure_ascii=False
54
+ ),
55
+ encoding="utf-8",
56
+ )
57
+ return index