pdf-reader-0jsfjf 0.1.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.
pdf_reader/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ PDF Reader - PDF indexing, search, and retrieval for AI applications.
3
+
4
+ This package provides:
5
+ - PDF/PDFPage: Data models for PDF documents and pages
6
+ - PDFIndex: Hybrid index combining Tantivy search with SQLite storage
7
+ - PDFService: Multi-index service layer for business logic
8
+ - Server implementations: REST API, MCP, and lm_deluge tools
9
+ - Formatting helpers: format_toc, format_ocr for LLM consumption
10
+ """
11
+
12
+ from .index import PDFIndex
13
+ from .pdf import PDF, PDFPage, format_toc, format_ocr
14
+ from .service import PDFService, PDFServiceConfig
15
+ from .ingest import ingest_pages_llm, iter_ingest_pages_llm
16
+ from .core import (
17
+ PDFIndexConfig,
18
+ SearchRequest,
19
+ SearchResponse,
20
+ ReadRequest,
21
+ ReadResponse,
22
+ )
23
+
24
+ __all__ = [
25
+ # Data models
26
+ "PDF",
27
+ "PDFPage",
28
+ # Index
29
+ "PDFIndex",
30
+ # Service
31
+ "PDFService",
32
+ "PDFServiceConfig",
33
+ # Config and API types
34
+ "PDFIndexConfig",
35
+ "SearchRequest",
36
+ "SearchResponse",
37
+ "ReadRequest",
38
+ "ReadResponse",
39
+ # Formatting helpers
40
+ "format_toc",
41
+ "format_ocr",
42
+ # Streaming OCR
43
+ "ingest_pages_llm",
44
+ "iter_ingest_pages_llm",
45
+ ]
pdf_reader/core.py ADDED
@@ -0,0 +1,150 @@
1
+ """
2
+ Core types, models, and utilities shared across all PDF service implementations.
3
+
4
+ This module provides:
5
+ - PDFIndexConfig dataclass for index configuration
6
+ - Request/Response Pydantic models for the unified API
7
+ - Utility functions for data processing
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ # =============================================================================
16
+ # Configuration
17
+ # =============================================================================
18
+
19
+
20
+ @dataclass
21
+ class PDFIndexConfig:
22
+ """Configuration for a single PDF index."""
23
+
24
+ name: str
25
+ description: str = ""
26
+ root_path: str | None = None # Directory with search/, pages.db, manifest.json
27
+
28
+
29
+ # =============================================================================
30
+ # Request/Response Models (Unified API)
31
+ # =============================================================================
32
+
33
+
34
+ class SearchRequest(BaseModel):
35
+ """Request model for search endpoint."""
36
+
37
+ index: str = Field(..., description="Name of the index to search")
38
+ queries: list[str] = Field(..., description="List of search query strings")
39
+ limit: int = Field(10, description="Maximum number of results to return")
40
+ pdf_filter: str | None = Field(
41
+ None, description="Optional PDF ID to filter results to a single document"
42
+ )
43
+ include_images: bool = Field(
44
+ False, description="If true, include page images in results"
45
+ )
46
+
47
+
48
+ class PageResult(BaseModel):
49
+ """A single page result from search."""
50
+
51
+ id: str = Field(..., description="Page ID in format 'pdf_id:page_index'")
52
+ pdf_id: str = Field(..., description="ID of the parent PDF")
53
+ page_index: int = Field(..., description="Zero-based page number")
54
+ text: str = Field("", description="OCR-extracted text")
55
+ summary: str | None = Field(None, description="AI-generated page summary")
56
+ caption: str | None = Field(
57
+ None, description="AI-generated caption for visual pages"
58
+ )
59
+ image: str | None = Field(None, description="Base64-encoded JPEG image")
60
+ preview: str | None = Field(None, description="Text preview for display")
61
+
62
+
63
+ class SearchResponse(BaseModel):
64
+ """Response model for search endpoint."""
65
+
66
+ index: str
67
+ queries: list[str]
68
+ results: list[PageResult]
69
+ total: int
70
+
71
+
72
+ class ReadRequest(BaseModel):
73
+ """Request model for read endpoint."""
74
+
75
+ index: str = Field(..., description="Name of the index to read from")
76
+ pdf_id: str = Field(..., description="ID of the PDF to read")
77
+ pages: list[int] | str = Field(
78
+ ...,
79
+ description="List of page indices to read, or a range string like '1-5' or '-1' for all",
80
+ )
81
+ include_images: bool = Field(True, description="If true, include page images")
82
+
83
+
84
+ class ReadResponse(BaseModel):
85
+ """Response model for read endpoint."""
86
+
87
+ index: str
88
+ pdf_id: str
89
+ pages: list[PageResult]
90
+
91
+
92
+ class PDFInfo(BaseModel):
93
+ """Information about a single PDF in an index."""
94
+
95
+ id: str
96
+ filename: str
97
+ title: str | None = None
98
+ summary: str | None = None
99
+ num_pages: int
100
+
101
+
102
+ class ListPDFsResponse(BaseModel):
103
+ """Response model for listing PDFs in an index."""
104
+
105
+ index: str
106
+ pdfs: list[PDFInfo]
107
+
108
+
109
+ class IndexInfo(BaseModel):
110
+ """Information about a single index."""
111
+
112
+ name: str
113
+ description: str = ""
114
+ pdf_count: int = 0
115
+
116
+
117
+ class IndicesResponse(BaseModel):
118
+ """Response model for listing indices."""
119
+
120
+ indices: list[IndexInfo]
121
+
122
+
123
+ class HealthResponse(BaseModel):
124
+ """Response model for health check."""
125
+
126
+ status: str
127
+ indices: list[str]
128
+ server: str | None = None
129
+
130
+
131
+ # =============================================================================
132
+ # Exports
133
+ # =============================================================================
134
+
135
+ __all__ = [
136
+ # Configuration
137
+ "PDFIndexConfig",
138
+ # Request models
139
+ "SearchRequest",
140
+ "ReadRequest",
141
+ # Response models
142
+ "PageResult",
143
+ "SearchResponse",
144
+ "ReadResponse",
145
+ "PDFInfo",
146
+ "ListPDFsResponse",
147
+ "IndexInfo",
148
+ "IndicesResponse",
149
+ "HealthResponse",
150
+ ]
@@ -0,0 +1,370 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import glob
5
+ import json
6
+ import shutil
7
+ import tempfile
8
+ import zipfile
9
+ from pathlib import Path
10
+ from typing import List, Literal, TypedDict
11
+
12
+ from pdf_reader.util import fetch_all_pdfs_concurrently, generate_pdf_hash, parse_range
13
+
14
+ from ..ingest import enrich, ingest_many
15
+ from ..pdf import PDF, PDFPage
16
+ from .page_store import PageStore
17
+ from .tantivy_ext import PageSearch
18
+
19
+
20
+ class File(TypedDict):
21
+ id: str
22
+ filename: str
23
+ presigned_url: str | None
24
+ bytes: bytes | None
25
+
26
+
27
+ class PDFIndex:
28
+ """
29
+ Hybrid index: Tantivy for retrieval; SQLite for random page access.
30
+ Directory layout:
31
+
32
+ <root>/
33
+ search/ (tantivy index)
34
+ pages.db (sqlite blob store)
35
+ manifest.json
36
+ """
37
+
38
+ def __init__(self, root: str, search: PageSearch, store: PageStore):
39
+ self.root = Path(root)
40
+ self.search = search
41
+ self.store = store
42
+ with open(self.root / "manifest.json") as f:
43
+ self.manifest = json.load(f) # maps pdf_id -> {title, num_pages}
44
+
45
+ # ---------- builders ----------
46
+ @classmethod
47
+ async def from_folder(
48
+ cls,
49
+ folder: str | Path,
50
+ root: str | Path,
51
+ do_enrich: bool = True,
52
+ ocr_backend: Literal["tesseract", "marker", "llm"] = "llm",
53
+ backend_llm: str | None = "gemini-2.5-flash",
54
+ cache_path: str | None = None,
55
+ max_concurrent_requests: int = 225,
56
+ keep_raw_bytes: bool = False,
57
+ ) -> "PDFIndex":
58
+ pdf_paths = glob.glob(f"{folder}/*.pdf")
59
+ filenames = [x.split("/")[-1] for x in pdf_paths]
60
+
61
+ return await PDFIndex.from_pdfs(
62
+ pdf_paths,
63
+ root,
64
+ filenames, # type: ignore
65
+ do_enrich,
66
+ ocr_backend,
67
+ backend_llm,
68
+ cache_path,
69
+ max_concurrent_requests,
70
+ keep_raw_bytes=keep_raw_bytes,
71
+ )
72
+
73
+ @classmethod
74
+ async def from_pdfs(
75
+ cls,
76
+ pdf_paths: List[str],
77
+ root: str | Path,
78
+ filenames: list[str | None] | None = None,
79
+ do_enrich: bool = True,
80
+ ocr_backend: Literal["tesseract", "marker", "llm"] = "llm",
81
+ backend_llm: str | None = "gemini-2.5-flash",
82
+ cache_path: str | None = None,
83
+ max_concurrent_requests: int = 225,
84
+ keep_raw_bytes: bool = False,
85
+ ) -> "PDFIndex":
86
+ root = Path(root)
87
+ if root.exists():
88
+ print(f"⚠️ Removing existing index dir {root}")
89
+ shutil.rmtree(root)
90
+ (root / "search").mkdir(parents=True, exist_ok=True)
91
+
92
+ # Ingest all PDFs
93
+ all_pages: List[PDFPage] = []
94
+ manifest = {}
95
+ tasks = []
96
+ if not filenames:
97
+ filenames = [None for _ in pdf_paths]
98
+
99
+ print("ingesting pdfs...")
100
+ pdfs: list[PDF] = await ingest_many(
101
+ pdf_paths,
102
+ filenames,
103
+ backend=ocr_backend,
104
+ backend_llm=backend_llm,
105
+ max_concurrent_requests=max_concurrent_requests,
106
+ keep_raw_bytes=keep_raw_bytes,
107
+ )
108
+
109
+ # if enriching, enrich the pdfs
110
+ print("enriching pdfs...")
111
+ if do_enrich:
112
+ tasks = []
113
+ for pdf in pdfs:
114
+ tasks.append(asyncio.create_task(enrich(pdf, cache_path=cache_path)))
115
+
116
+ await asyncio.gather(*tasks)
117
+
118
+ for pdf in pdfs:
119
+ manifest[pdf.id] = {
120
+ "id": pdf.id,
121
+ "pdf_id": pdf.id,
122
+ "title": pdf.title,
123
+ "filename": pdf.filename,
124
+ "summary": pdf.summary,
125
+ "pages": pdf.num_pages,
126
+ }
127
+ all_pages.extend(pdf.pages)
128
+
129
+ # Build search index
130
+ search = PageSearch(str(root / "search"))
131
+ search.build(pdfs)
132
+
133
+ # Build page store (with raw PDF bytes if requested)
134
+ store = PageStore(str(root / "pages.db"))
135
+ store.build(all_pages, pdfs=pdfs if keep_raw_bytes else None)
136
+
137
+ # Persist manifest
138
+ with open(root / "manifest.json", "w") as f:
139
+ json.dump(manifest, f, indent=2)
140
+
141
+ return cls(str(root), search, store)
142
+
143
+ @classmethod
144
+ def from_pdf_objects(
145
+ cls,
146
+ pdfs: List["PDF"],
147
+ root: str | Path,
148
+ keep_raw_bytes: bool = False,
149
+ ) -> "PDFIndex":
150
+ """
151
+ Build an index from pre-constructed PDF objects.
152
+
153
+ This is useful when PDFs have already been OCR'd and loaded
154
+ (e.g., from S3 flat files) and you just need to build the index.
155
+
156
+ Args:
157
+ pdfs: List of PDF objects with pages already populated
158
+ root: Directory to store the index
159
+ keep_raw_bytes: Whether to store raw PDF bytes (if available on PDF objects)
160
+
161
+ Returns:
162
+ PDFIndex ready for searching
163
+ """
164
+ root = Path(root)
165
+ if root.exists():
166
+ shutil.rmtree(root)
167
+ (root / "search").mkdir(parents=True, exist_ok=True)
168
+
169
+ # Build manifest and collect pages
170
+ manifest = {}
171
+ all_pages: List[PDFPage] = []
172
+
173
+ for pdf in pdfs:
174
+ manifest[pdf.id] = {
175
+ "id": pdf.id,
176
+ "pdf_id": pdf.id,
177
+ "title": pdf.title,
178
+ "filename": pdf.filename,
179
+ "summary": pdf.summary,
180
+ "pages": len(pdf.pages),
181
+ }
182
+ all_pages.extend(pdf.pages)
183
+
184
+ # Build search index
185
+ search = PageSearch(str(root / "search"))
186
+ search.build(pdfs)
187
+
188
+ # Build page store
189
+ store = PageStore(str(root / "pages.db"))
190
+ store.build(
191
+ all_pages,
192
+ pdfs=pdfs if keep_raw_bytes and any(p.raw_bytes for p in pdfs) else None,
193
+ )
194
+
195
+ # Persist manifest
196
+ with open(root / "manifest.json", "w") as f:
197
+ json.dump(manifest, f, indent=2)
198
+
199
+ return cls(str(root), search, store)
200
+
201
+ @classmethod
202
+ def load(cls, root: str | Path) -> "PDFIndex":
203
+ root = Path(root)
204
+ if not (root / "manifest.json").exists():
205
+ raise FileNotFoundError("Not a valid PDFIndex directory")
206
+ search = PageSearch.open(str(root / "search"))
207
+ store = PageStore.open(str(root / "pages.db"))
208
+ return cls(str(root), search, store)
209
+
210
+ def save(self, *_):
211
+ """No‑op – everything is already on disk."""
212
+ pass
213
+
214
+ def save_to_file(self, filepath: str | Path) -> None:
215
+ """Save the entire PDFIndex to a single ZIP file."""
216
+ filepath = Path(filepath)
217
+ with zipfile.ZipFile(filepath, "w", zipfile.ZIP_DEFLATED) as zipf:
218
+ for file_path in self.root.rglob("*"):
219
+ if file_path.is_file():
220
+ arcname = file_path.relative_to(self.root)
221
+ zipf.write(file_path, arcname)
222
+
223
+ @classmethod
224
+ def load_from_file(
225
+ cls, filepath: str | Path, extract_to: str | Path | None = None
226
+ ) -> "PDFIndex":
227
+ """Load a PDFIndex from a single ZIP file."""
228
+ filepath = Path(filepath)
229
+
230
+ if extract_to is None:
231
+ extract_to = tempfile.mkdtemp(prefix="pdfindex_")
232
+ else:
233
+ extract_to = Path(extract_to)
234
+
235
+ with zipfile.ZipFile(filepath, "r") as zipf:
236
+ zipf.extractall(extract_to)
237
+
238
+ return cls.load(extract_to)
239
+
240
+ # ---------- API ----------
241
+ def search_pages(
242
+ self, queries: List[str], pdf_id: str | None = None, limit: int = 10
243
+ ) -> List[PDFPage]:
244
+ """Keyword search across all PDFs (or a single PDF)."""
245
+ ids = self.search.search(queries, pdf_filter=pdf_id, limit=limit)
246
+ return [self.store.fetch_page(pid) for pid in ids]
247
+
248
+ def read(self, pdf_id: str, pages: List[int] | str) -> List[PDFPage]:
249
+ if isinstance(pages, str):
250
+ pages = parse_range(pages)
251
+ return self.store.fetch_pages(pdf_id, pages)
252
+
253
+ def list_pdfs(self) -> List[dict]:
254
+ """List all PDFs in the index with their metadata."""
255
+ # could just do return self.manifest.values()
256
+ return [metadata for pdf_id, metadata in self.manifest.items()]
257
+
258
+ def get_pdfs(self, include_pages: bool = True) -> List[PDF]:
259
+ """Get all PDFs as PDF objects.
260
+
261
+ Args:
262
+ include_pages: If True, include all pages. If False, return PDFs with empty page lists.
263
+
264
+ Returns:
265
+ List of PDF objects with metadata and optionally pages.
266
+ """
267
+ pdfs = []
268
+ for pdf_id, meta in self.manifest.items():
269
+ if include_pages:
270
+ pages = self.store.fetch_pages(pdf_id, [-1]) # -1 means all pages
271
+ else:
272
+ pages = []
273
+
274
+ pdf = PDF(
275
+ id=pdf_id,
276
+ filename=meta.get("filename", ""),
277
+ title=meta.get("title"),
278
+ summary=meta.get("summary"),
279
+ pages=pages,
280
+ raw_bytes=self.store.fetch_raw_bytes(pdf_id),
281
+ )
282
+ pdfs.append(pdf)
283
+ return pdfs
284
+
285
+ # ---------- raw PDF access ----------
286
+ def get_raw_bytes(self, pdf_id: str) -> bytes | None:
287
+ """Get raw PDF bytes for a specific PDF. Returns None if not stored."""
288
+ return self.store.fetch_raw_bytes(pdf_id)
289
+
290
+ def get_all_raw_bytes(self) -> dict[str, bytes]:
291
+ """Get all raw PDF bytes. Returns dict of pdf_id -> bytes."""
292
+ return self.store.fetch_all_raw_bytes()
293
+
294
+ def has_raw_bytes(self) -> bool:
295
+ """Check if this index has raw PDF bytes stored."""
296
+ return self.store.has_pdfs_table()
297
+
298
+
299
+ async def get_or_create_pdf_index(
300
+ files: list[File],
301
+ cache_path: str,
302
+ llm_cache_path: str,
303
+ max_concurrent_requests: int = 225,
304
+ ) -> tuple[PDFIndex, str]:
305
+ """Get cached PDF index or create new one if cache miss."""
306
+
307
+ presigned_urls = [
308
+ file["presigned_url"] for file in files if file.get("presigned_url")
309
+ ]
310
+ bytes_pdf_contents = [file["bytes"] for file in files if file.get("bytes")]
311
+
312
+ if presigned_urls:
313
+ print("Fetching PDFs concurrently...")
314
+ url_pdf_contents = await fetch_all_pdfs_concurrently(presigned_urls) # type: ignore
315
+ else:
316
+ url_pdf_contents = []
317
+
318
+ pdf_contents = url_pdf_contents + bytes_pdf_contents
319
+ filenames = [f["filename"] for f in files]
320
+
321
+ # Generate order-invariant hash
322
+ content_hash = generate_pdf_hash(pdf_contents) # type: ignore
323
+ print(f"Generated content hash: {content_hash}")
324
+
325
+ # Check if cached index exists
326
+ cache_dir = Path(cache_path)
327
+ cache_dir.mkdir(exist_ok=True)
328
+ cache_file = cache_dir / f"{content_hash}.pkl"
329
+
330
+ if cache_file.exists():
331
+ print(f"Loading cached index from {cache_file}")
332
+ return PDFIndex.load_from_file(str(cache_file)), content_hash
333
+ else:
334
+ print(f"Cache miss. Creating new index and saving to {cache_file}")
335
+
336
+ # Save PDFs to temporary files
337
+ temp_files = []
338
+ try:
339
+ for i, content in enumerate(pdf_contents):
340
+ if not content:
341
+ continue
342
+ temp_file = tempfile.NamedTemporaryFile(
343
+ delete=False, suffix=f"_{i}.pdf"
344
+ )
345
+ temp_file.write(content)
346
+ temp_file.close()
347
+ temp_files.append(temp_file.name)
348
+
349
+ # Create new index from local files
350
+ index = await PDFIndex.from_pdfs(
351
+ temp_files,
352
+ filenames=filenames, # type: ignore
353
+ root="./pdf-index",
354
+ ocr_backend="llm",
355
+ backend_llm="gemini-2.5-flash",
356
+ cache_path=llm_cache_path,
357
+ max_concurrent_requests=max_concurrent_requests,
358
+ )
359
+
360
+ # Save to cache
361
+ index.save_to_file(str(cache_file))
362
+ return index, content_hash
363
+
364
+ finally:
365
+ # Clean up temporary files
366
+ for temp_file in temp_files:
367
+ try:
368
+ Path(temp_file).unlink()
369
+ except FileNotFoundError:
370
+ pass