llmPDF 0.7.0__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.
Files changed (62) hide show
  1. llmpdf/__init__.py +36 -0
  2. llmpdf/__main__.py +3 -0
  3. llmpdf/agent_scheduler.py +221 -0
  4. llmpdf/assets_task.py +174 -0
  5. llmpdf/batch.py +418 -0
  6. llmpdf/cli.py +472 -0
  7. llmpdf/detection_task.py +543 -0
  8. llmpdf/docling_task.py +342 -0
  9. llmpdf/docling_worker.py +37 -0
  10. llmpdf/extraction_task.py +371 -0
  11. llmpdf/image_analysis_task.py +1210 -0
  12. llmpdf/images_task.py +122 -0
  13. llmpdf/io_utils.py +62 -0
  14. llmpdf/merge_rules/__init__.py +9 -0
  15. llmpdf/merge_rules/find_image_absorbed_blocks.py +29 -0
  16. llmpdf/merge_rules/find_table_absorbed_blocks.py +18 -0
  17. llmpdf/merge_rules/is_protected_block.py +6 -0
  18. llmpdf/merge_task.py +639 -0
  19. llmpdf/metrics_task.py +236 -0
  20. llmpdf/models.py +150 -0
  21. llmpdf/pages.py +69 -0
  22. llmpdf/pi_runtime.py +5 -0
  23. llmpdf/pipeline.py +164 -0
  24. llmpdf/preflight.py +97 -0
  25. llmpdf/progress.py +12 -0
  26. llmpdf/rerun.py +205 -0
  27. llmpdf/retention.py +185 -0
  28. llmpdf/review.py +43 -0
  29. llmpdf/review_discovery.py +54 -0
  30. llmpdf/review_project.py +42 -0
  31. llmpdf/review_server.py +114 -0
  32. llmpdf/review_source.py +431 -0
  33. llmpdf/review_static/assets/index-Ch2-xtmb.js +90 -0
  34. llmpdf/review_static/assets/index-DYQ2zsXw.css +1 -0
  35. llmpdf/review_static/assets/pdf.worker.min-Dswkl-cV.mjs +29 -0
  36. llmpdf/review_static/index.html +14 -0
  37. llmpdf/review_tables.py +101 -0
  38. llmpdf/run_status.py +206 -0
  39. llmpdf/screenshots_task.py +151 -0
  40. llmpdf/sdk.py +477 -0
  41. llmpdf/table/__init__.py +3 -0
  42. llmpdf/table/__main__.py +5 -0
  43. llmpdf/table/agent_runner.py +229 -0
  44. llmpdf/table/cli.py +176 -0
  45. llmpdf/table/io_utils.py +64 -0
  46. llmpdf/table/models.py +49 -0
  47. llmpdf/table/orchestration.py +436 -0
  48. llmpdf/table/pi_runtime.py +65 -0
  49. llmpdf/table/prepare.py +222 -0
  50. llmpdf/table/prompt.py +253 -0
  51. llmpdf/table/run_all.py +63 -0
  52. llmpdf/table/runner.py +41 -0
  53. llmpdf/table/runtime.py +170 -0
  54. llmpdf/table/table_guard.py +230 -0
  55. llmpdf/task.py +117 -0
  56. llmpdf/validate_task.py +309 -0
  57. llmpdf-0.7.0.dist-info/METADATA +488 -0
  58. llmpdf-0.7.0.dist-info/RECORD +62 -0
  59. llmpdf-0.7.0.dist-info/WHEEL +5 -0
  60. llmpdf-0.7.0.dist-info/entry_points.txt +3 -0
  61. llmpdf-0.7.0.dist-info/licenses/LICENSE +103 -0
  62. llmpdf-0.7.0.dist-info/top_level.txt +1 -0
llmpdf/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Task-oriented llmPDF conversion pipeline."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("llmPDF")
7
+ except PackageNotFoundError: # Running directly from an unpacked source tree.
8
+ __version__ = "0+unknown"
9
+
10
+ from .sdk import (
11
+ BillingSummary,
12
+ ConfigurationError,
13
+ ConversionError,
14
+ ConversionResult,
15
+ ConvertOptions,
16
+ DocumentConverterProtocol,
17
+ TaskExecutionError,
18
+ TokenUsage,
19
+ UsageSummary,
20
+ convert,
21
+ )
22
+ from .rerun import rerun_tables
23
+
24
+ __all__ = [
25
+ "BillingSummary",
26
+ "ConfigurationError",
27
+ "ConversionError",
28
+ "ConversionResult",
29
+ "ConvertOptions",
30
+ "DocumentConverterProtocol",
31
+ "TaskExecutionError",
32
+ "TokenUsage",
33
+ "UsageSummary",
34
+ "convert",
35
+ "rerun_tables",
36
+ ]
llmpdf/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from collections import deque
6
+ from collections.abc import Callable
7
+ from concurrent.futures import Executor, Future
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+
12
+ TASK_ORDER = ("find", "cross_table", "table", "image")
13
+
14
+
15
+ @dataclass
16
+ class _QueuedTask:
17
+ future: Future
18
+ kind: str
19
+ label: str
20
+ callback: Callable[..., Any]
21
+ args: tuple[Any, ...]
22
+ kwargs: dict[str, Any]
23
+ submitted_at: float
24
+ sequence: int
25
+
26
+
27
+ class PriorityAgentExecutor(Executor):
28
+ """Fixed worker pool backed by ordered, per-task-type FIFO queues."""
29
+
30
+ def __init__(
31
+ self,
32
+ max_workers: int = 5,
33
+ *,
34
+ kind_limits: dict[str, int] | None = None,
35
+ progress: Callable[[str], None] | None = None,
36
+ ) -> None:
37
+ if not 1 <= max_workers <= 5:
38
+ raise ValueError("max_workers must be between 1 and 5")
39
+ self.max_workers = max_workers
40
+ configured_limits = kind_limits or {}
41
+ self._kind_limits = {
42
+ "find": max(1, int(configured_limits.get("find", max_workers))),
43
+ "table": max(1, int(configured_limits.get("table", max_workers))),
44
+ "image": max(1, int(configured_limits.get("image", max_workers))),
45
+ }
46
+ self._progress = progress
47
+ self._queues: dict[str, deque[_QueuedTask]] = {
48
+ kind: deque() for kind in TASK_ORDER
49
+ }
50
+ self._condition = threading.Condition()
51
+ self._shutdown = False
52
+ self._sequence = 0
53
+ self._running: dict[int, _QueuedTask] = {}
54
+ self._peak_running = 0
55
+ self._completed = 0
56
+ self._records: list[dict[str, Any]] = []
57
+ self._threads = [
58
+ threading.Thread(
59
+ target=self._worker,
60
+ name=f"pdf-agent-{index + 1}",
61
+ daemon=True,
62
+ )
63
+ for index in range(max_workers)
64
+ ]
65
+ for thread in self._threads:
66
+ thread.start()
67
+ self._emit(f"queue started: workers={max_workers}")
68
+
69
+ def submit(self, fn, /, *args, **kwargs): # type: ignore[override]
70
+ return self.submit_task("table", getattr(fn, "__name__", "table"), fn, *args, **kwargs)
71
+
72
+ def submit_task(
73
+ self,
74
+ kind: str,
75
+ label: str,
76
+ fn: Callable[..., Any],
77
+ /,
78
+ *args: Any,
79
+ **kwargs: Any,
80
+ ) -> Future:
81
+ if kind not in self._queues:
82
+ raise ValueError(f"Unknown Agent task type: {kind}")
83
+ future: Future = Future()
84
+ with self._condition:
85
+ if self._shutdown:
86
+ raise RuntimeError("cannot schedule new futures after shutdown")
87
+ self._sequence += 1
88
+ task = _QueuedTask(
89
+ future=future,
90
+ kind=kind,
91
+ label=label,
92
+ callback=fn,
93
+ args=args,
94
+ kwargs=kwargs,
95
+ submitted_at=time.monotonic(),
96
+ sequence=self._sequence,
97
+ )
98
+ self._queues[kind].append(task)
99
+ state = self._state_locked()
100
+ self._condition.notify()
101
+ self._emit(f"queued {kind} {label}; {state}")
102
+ return future
103
+
104
+ def _running_count_locked(self, kind: str) -> int:
105
+ if kind in {"cross_table", "table"}:
106
+ return sum(
107
+ task.kind in {"cross_table", "table"}
108
+ for task in self._running.values()
109
+ )
110
+ return sum(task.kind == kind for task in self._running.values())
111
+
112
+ def _can_run_locked(self, kind: str) -> bool:
113
+ limit_kind = "table" if kind == "cross_table" else kind
114
+ return self._running_count_locked(kind) < self._kind_limits[limit_kind]
115
+
116
+ def _next_locked(self) -> _QueuedTask | None:
117
+ for kind in TASK_ORDER:
118
+ if self._queues[kind] and self._can_run_locked(kind):
119
+ return self._queues[kind].popleft()
120
+ return None
121
+
122
+ def _has_queued_locked(self) -> bool:
123
+ return any(self._queues[kind] for kind in TASK_ORDER)
124
+
125
+ def _has_runnable_locked(self) -> bool:
126
+ return any(
127
+ self._queues[kind] and self._can_run_locked(kind)
128
+ for kind in TASK_ORDER
129
+ )
130
+
131
+ def _state_locked(self) -> str:
132
+ ready = " ".join(
133
+ f"{kind}={len(self._queues[kind])}" for kind in TASK_ORDER
134
+ )
135
+ discovered = self._completed + len(self._running) + sum(
136
+ len(queue) for queue in self._queues.values()
137
+ )
138
+ return (
139
+ f"running={len(self._running)}/{self.max_workers} "
140
+ f"ready[{ready}] completed={self._completed} discovered={discovered}"
141
+ )
142
+
143
+ def _emit(self, message: str) -> None:
144
+ if self._progress is not None:
145
+ self._progress(f"[scheduler] {message}")
146
+
147
+ def _worker(self) -> None:
148
+ while True:
149
+ with self._condition:
150
+ self._condition.wait_for(
151
+ lambda: self._has_runnable_locked()
152
+ or (self._shutdown and not self._has_queued_locked())
153
+ )
154
+ task = self._next_locked()
155
+ if task is None:
156
+ if self._shutdown and not self._has_queued_locked():
157
+ return
158
+ continue
159
+ if not task.future.set_running_or_notify_cancel():
160
+ continue
161
+ worker_id = threading.get_ident()
162
+ self._running[worker_id] = task
163
+ self._peak_running = max(self._peak_running, len(self._running))
164
+ state = self._state_locked()
165
+ self._emit(f"started {task.kind} {task.label}; {state}")
166
+ started_at = time.monotonic()
167
+ try:
168
+ result = task.callback(*task.args, **task.kwargs)
169
+ except BaseException as error:
170
+ task.future.set_exception(error)
171
+ status = "failed"
172
+ else:
173
+ task.future.set_result(result)
174
+ status = "completed"
175
+ finished_at = time.monotonic()
176
+ with self._condition:
177
+ self._running.pop(worker_id, None)
178
+ self._completed += 1
179
+ self._records.append(
180
+ {
181
+ "sequence": task.sequence,
182
+ "type": task.kind,
183
+ "label": task.label,
184
+ "status": status,
185
+ "queue_wait_seconds": round(started_at - task.submitted_at, 3),
186
+ "run_seconds": round(finished_at - started_at, 3),
187
+ }
188
+ )
189
+ state = self._state_locked()
190
+ self._condition.notify_all()
191
+ self._emit(
192
+ f"{status} {task.kind} {task.label} in "
193
+ f"{finished_at - started_at:.1f}s; {state}"
194
+ )
195
+
196
+ def snapshot(self) -> dict[str, Any]:
197
+ with self._condition:
198
+ return {
199
+ "task_order": list(TASK_ORDER),
200
+ "max_workers": self.max_workers,
201
+ "kind_limits": dict(self._kind_limits),
202
+ "completed": self._completed,
203
+ "running": len(self._running),
204
+ "peak_running": self._peak_running,
205
+ "ready": {
206
+ kind: len(self._queues[kind]) for kind in TASK_ORDER
207
+ },
208
+ "tasks": list(self._records),
209
+ }
210
+
211
+ def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None:
212
+ with self._condition:
213
+ self._shutdown = True
214
+ if cancel_futures:
215
+ for queue in self._queues.values():
216
+ while queue:
217
+ queue.popleft().future.cancel()
218
+ self._condition.notify_all()
219
+ if wait:
220
+ for thread in self._threads:
221
+ thread.join()
llmpdf/assets_task.py ADDED
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import re
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from .io_utils import copy_file, read_json, relativize, sha256_file, write_json
11
+ from .models import BBox, PipelineConfig, TaskResult
12
+ from .task import PipelineTask
13
+
14
+
15
+ def infer_header_rows(name: str | None) -> int:
16
+ normalized = (name or "").casefold()
17
+ headerless_terms = ("abkürz", "abbreviation", "glossar", "glossary")
18
+ return 0 if any(term in normalized for term in headerless_terms) else 1
19
+
20
+
21
+ def csv_to_markdown(
22
+ csv_path: Path, title: str | None, header_rows: int | None = None
23
+ ) -> str:
24
+ with csv_path.open(encoding="utf-8-sig", newline="") as stream:
25
+ rows = list(csv.reader(stream))
26
+ if not rows:
27
+ return ""
28
+ width = max(len(row) for row in rows)
29
+ rows = [row + [""] * (width - len(row)) for row in rows]
30
+
31
+ def clean(value: str) -> str:
32
+ return re.sub(r"\s*[\r\n]+\s*", " ", value).replace("|", "\\|")
33
+
34
+ lines = []
35
+ if title:
36
+ lines.extend([f"**{title.strip()}**", ""])
37
+ header_rows = infer_header_rows(title) if header_rows is None else header_rows
38
+ header = rows[0] if header_rows else [""] * width
39
+ body = rows[1:] if header_rows else rows
40
+ lines.append("| " + " | ".join(clean(value) for value in header) + " |")
41
+ lines.append("| " + " | ".join("---" for _ in range(width)) + " |")
42
+ lines.extend(
43
+ "| " + " | ".join(clean(value) for value in row) + " |" for row in body
44
+ )
45
+ return "\n".join(lines).rstrip() + "\n"
46
+
47
+
48
+ class CollectAssetsTask(PipelineTask):
49
+ name = "06-collect-assets"
50
+ dependencies = ("05-extract-tables",)
51
+
52
+ def signature_payload(self, config: PipelineConfig) -> dict:
53
+ value = super().signature_payload(config)
54
+ value["asset_logic_version"] = 6
55
+ summary = config.work_dir / "table-extraction" / "summary.json"
56
+ if summary.is_file():
57
+ value["extraction_summary_sha256"] = sha256_file(summary)
58
+ extraction = read_json(summary)
59
+ artifacts: dict[str, str] = {}
60
+ for relative_directory in extraction.get("table_directories", []):
61
+ directory = config.output_dir / relative_directory
62
+ for pattern in ("output_*.csv", "extract.py", "metadata.yaml"):
63
+ for path in sorted(directory.glob(pattern)):
64
+ artifacts[relativize(path, config.output_dir)] = sha256_file(
65
+ path
66
+ )
67
+ value["extraction_artifacts"] = artifacts
68
+ return value
69
+
70
+ def run(self, config: PipelineConfig) -> TaskResult:
71
+ extraction = read_json(config.work_dir / "table-extraction" / "summary.json")
72
+ source_dirs = [
73
+ config.output_dir / path for path in extraction.get("table_directories", [])
74
+ ]
75
+ continuation_by_leader = {
76
+ int(group["leader_page"]): [int(page) for page in group["pages"]]
77
+ for group in extraction.get("continuation_groups", [])
78
+ }
79
+ records = []
80
+ sortable = []
81
+ for source_dir in source_dirs:
82
+ metadata = yaml.safe_load(
83
+ (source_dir / "metadata.yaml").read_text(encoding="utf-8")
84
+ )
85
+ sortable.append(
86
+ (
87
+ int(metadata["page"]),
88
+ int(metadata["page_table_index"]),
89
+ source_dir,
90
+ metadata,
91
+ )
92
+ )
93
+ sortable.sort()
94
+
95
+ internal_root = config.work_dir / "table-assets"
96
+ tables_root = config.assets_dir / "tables"
97
+ if config.force:
98
+ for path in (internal_root, tables_root):
99
+ if path.exists():
100
+ shutil.rmtree(path)
101
+ internal_root.mkdir(parents=True, exist_ok=True)
102
+ tables_root.mkdir(parents=True, exist_ok=True)
103
+ for global_index, (page, page_index, source_dir, metadata) in enumerate(
104
+ sortable, 1
105
+ ):
106
+ table_id = f"table-{global_index:04d}"
107
+ internal = internal_root / table_id
108
+ internal.mkdir(parents=True, exist_ok=True)
109
+ csv_paths = sorted(source_dir.glob("output_*.csv"))
110
+ if not csv_paths:
111
+ continue
112
+ primary_csv = copy_file(csv_paths[0], tables_root / f"{table_id}.csv")
113
+ extra_csvs = []
114
+ for extra_index, extra in enumerate(csv_paths[1:], 2):
115
+ extra_csvs.append(
116
+ copy_file(extra, tables_root / f"{table_id}-{extra_index}.csv")
117
+ )
118
+ extractor = copy_file(source_dir / "extract.py", internal / "extract.py")
119
+ source_metadata = copy_file(
120
+ source_dir / "metadata.yaml", internal / "metadata.yaml"
121
+ )
122
+ markdown_path = internal / "table.md"
123
+ header_rows = infer_header_rows(metadata.get("name"))
124
+ markdown_path.write_text(
125
+ csv_to_markdown(primary_csv, metadata.get("name"), header_rows),
126
+ encoding="utf-8",
127
+ )
128
+
129
+ bbox_data = metadata["bbox"]
130
+ bbox = BBox.from_dict(bbox_data)
131
+ source_pages = [
132
+ int(value) for value in metadata.get("source_pages", [page])
133
+ ]
134
+ if source_pages == [page] and page in continuation_by_leader:
135
+ anchored = [
136
+ item
137
+ for item in sortable
138
+ if int(item[0]) == page and f"page-{page:04d}" in item[2].parts
139
+ ]
140
+ # Older Agent outputs did not record source_pages. The fallback
141
+ # is safe only when the continuation leader has one anchored table.
142
+ if len(anchored) == 1:
143
+ source_pages = continuation_by_leader[page]
144
+ record = {
145
+ "id": table_id,
146
+ "page": page,
147
+ "source_pages": source_pages,
148
+ "page_table_index": page_index,
149
+ "name": metadata.get("name"),
150
+ "header_rows": header_rows,
151
+ "bbox": {
152
+ "coordinate_system": "pdfplumber_top_left",
153
+ "unit": "pt",
154
+ **bbox.to_dict(),
155
+ },
156
+ "csv": relativize(primary_csv, config.output_dir),
157
+ "extra_csvs": [
158
+ relativize(path, config.output_dir) for path in extra_csvs
159
+ ],
160
+ "internal": {
161
+ "markdown": relativize(markdown_path, config.output_dir),
162
+ "extractor": relativize(extractor, config.output_dir),
163
+ "metadata": relativize(source_metadata, config.output_dir),
164
+ },
165
+ }
166
+ records.append(record)
167
+ manifest = internal_root / "tables.json"
168
+ write_json(manifest, {"schema_version": 2, "tables": records})
169
+ return TaskResult(
170
+ self.name,
171
+ "completed",
172
+ [relativize(manifest, config.output_dir)],
173
+ {"table_count": len(records)},
174
+ )