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 +45 -0
- pdf_reader/core.py +150 -0
- pdf_reader/index/__init__.py +370 -0
- pdf_reader/index/page_store.py +191 -0
- pdf_reader/index/tantivy_ext.py +142 -0
- pdf_reader/ingest/__init__.py +979 -0
- pdf_reader/ingest/clean.py +301 -0
- pdf_reader/ingest/prompts.py +42 -0
- pdf_reader/pdf/__init__.py +125 -0
- pdf_reader/server/__init__.py +44 -0
- pdf_reader/server/api.py +186 -0
- pdf_reader/server/image.py +99 -0
- pdf_reader/server/mcp.py +529 -0
- pdf_reader/server/tools.py +321 -0
- pdf_reader/service.py +254 -0
- pdf_reader/util.py +77 -0
- pdf_reader_0jsfjf-0.1.0.dist-info/METADATA +67 -0
- pdf_reader_0jsfjf-0.1.0.dist-info/RECORD +19 -0
- pdf_reader_0jsfjf-0.1.0.dist-info/WHEEL +4 -0
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
|