vera-ingest 0.2.1__tar.gz
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.
- vera_ingest-0.2.1/.gitignore +13 -0
- vera_ingest-0.2.1/PKG-INFO +40 -0
- vera_ingest-0.2.1/README.md +20 -0
- vera_ingest-0.2.1/pyproject.toml +32 -0
- vera_ingest-0.2.1/src/vera_ingest/__init__.py +41 -0
- vera_ingest-0.2.1/src/vera_ingest/chunking.py +204 -0
- vera_ingest-0.2.1/src/vera_ingest/convert.py +472 -0
- vera_ingest-0.2.1/src/vera_ingest/parsers/__init__.py +3 -0
- vera_ingest-0.2.1/src/vera_ingest/parsers/pdf.py +594 -0
- vera_ingest-0.2.1/src/vera_ingest/tessdata/README.md +13 -0
- vera_ingest-0.2.1/src/vera_ingest/tessdata/eng.traineddata +0 -0
- vera_ingest-0.2.1/src/vera_ingest/viewer.py +156 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vera-ingest
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Source ingestion, chunking, and conversion adapters for VERA
|
|
5
|
+
Project-URL: Homepage, https://github.com/dkylewillis/vera
|
|
6
|
+
Project-URL: Repository, https://github.com/dkylewillis/vera
|
|
7
|
+
Project-URL: Documentation, https://dkylewillis.github.io/vera/packages/vera-ingest/
|
|
8
|
+
Author: Kyle Willis
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: chunking,ocr,pdf,semantic-search,vera
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: pdfplumber>=0.11
|
|
17
|
+
Requires-Dist: pymupdf>=1.23
|
|
18
|
+
Requires-Dist: vera-doc>=0.2.1
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# vera-ingest
|
|
22
|
+
|
|
23
|
+
`vera-ingest` contains VERA's source ingestion pipeline: PDF parsing, table
|
|
24
|
+
extraction, selective OCR, heading detection, chunking, and conversion.
|
|
25
|
+
|
|
26
|
+
It emits ready-made `vera.ChunkRecord` values and optional opaque attachments,
|
|
27
|
+
then stores them through `vera.VeraDocument`. It also provides
|
|
28
|
+
`vera_ingest.viewer` helpers that interpret ingest-produced page, figure,
|
|
29
|
+
region, and source-document conventions.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python -m pip install "vera-ingest>=0.2.1"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
See the [vera-ingest documentation](https://dkylewillis.github.io/vera/packages/vera-ingest/)
|
|
38
|
+
for concepts, examples, and API reference.
|
|
39
|
+
|
|
40
|
+
See the [conversion guide](https://github.com/dkylewillis/vera/blob/main/docs/conversion.md).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# vera-ingest
|
|
2
|
+
|
|
3
|
+
`vera-ingest` contains VERA's source ingestion pipeline: PDF parsing, table
|
|
4
|
+
extraction, selective OCR, heading detection, chunking, and conversion.
|
|
5
|
+
|
|
6
|
+
It emits ready-made `vera.ChunkRecord` values and optional opaque attachments,
|
|
7
|
+
then stores them through `vera.VeraDocument`. It also provides
|
|
8
|
+
`vera_ingest.viewer` helpers that interpret ingest-produced page, figure,
|
|
9
|
+
region, and source-document conventions.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
python -m pip install "vera-ingest>=0.2.1"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
See the [vera-ingest documentation](https://dkylewillis.github.io/vera/packages/vera-ingest/)
|
|
18
|
+
for concepts, examples, and API reference.
|
|
19
|
+
|
|
20
|
+
See the [conversion guide](https://github.com/dkylewillis/vera/blob/main/docs/conversion.md).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vera-ingest"
|
|
3
|
+
version = "0.2.1"
|
|
4
|
+
description = "Source ingestion, chunking, and conversion adapters for VERA"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"vera-doc>=0.2.1",
|
|
9
|
+
"pdfplumber>=0.11",
|
|
10
|
+
"pymupdf>=1.23",
|
|
11
|
+
]
|
|
12
|
+
authors = [{name = "Kyle Willis"}]
|
|
13
|
+
license = {text = "Apache-2.0"}
|
|
14
|
+
keywords = ["pdf", "ocr", "chunking", "semantic-search", "vera"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Text Processing :: Indexing",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/dkylewillis/vera"
|
|
24
|
+
Repository = "https://github.com/dkylewillis/vera"
|
|
25
|
+
Documentation = "https://dkylewillis.github.io/vera/packages/vera-ingest/"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/vera_ingest"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Source ingestion, chunking, and conversion adapters for VERA."""
|
|
2
|
+
|
|
3
|
+
from .chunking import (
|
|
4
|
+
Chunk,
|
|
5
|
+
build_chunks_from_blocks,
|
|
6
|
+
chunk_pages,
|
|
7
|
+
detect_heading,
|
|
8
|
+
)
|
|
9
|
+
from .convert import batch_convert, convert
|
|
10
|
+
from .parsers import ParsedBlock, ParsedPage, parse_pdf, parse_pdf_structured
|
|
11
|
+
from .viewer import (
|
|
12
|
+
export_source_document,
|
|
13
|
+
figures,
|
|
14
|
+
figures_for,
|
|
15
|
+
get_blocks,
|
|
16
|
+
get_chunk_regions,
|
|
17
|
+
get_page,
|
|
18
|
+
get_source_document,
|
|
19
|
+
regions_for,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"convert",
|
|
24
|
+
"batch_convert",
|
|
25
|
+
"Chunk",
|
|
26
|
+
"ParsedBlock",
|
|
27
|
+
"ParsedPage",
|
|
28
|
+
"build_chunks_from_blocks",
|
|
29
|
+
"chunk_pages",
|
|
30
|
+
"detect_heading",
|
|
31
|
+
"parse_pdf",
|
|
32
|
+
"parse_pdf_structured",
|
|
33
|
+
"export_source_document",
|
|
34
|
+
"figures",
|
|
35
|
+
"figures_for",
|
|
36
|
+
"get_blocks",
|
|
37
|
+
"get_chunk_regions",
|
|
38
|
+
"get_page",
|
|
39
|
+
"get_source_document",
|
|
40
|
+
"regions_for",
|
|
41
|
+
]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from .parsers import ParsedBlock
|
|
7
|
+
|
|
8
|
+
_HEADING_RE = re.compile(r"^(chapter|section|article|part|appendix|stormwater|zoning|[0-9]+(?:\.[0-9]+)*)\b", re.I)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Chunk:
|
|
13
|
+
"""Text segment produced by the extraction chunker.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
text: Chunk content.
|
|
17
|
+
page_start: First page number spanned by the chunk.
|
|
18
|
+
page_end: Last page number spanned by the chunk.
|
|
19
|
+
heading_path: Heading breadcrumb at chunk start.
|
|
20
|
+
token_count: Approximate token count.
|
|
21
|
+
block_ids: Source layout block identifiers.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
text: str
|
|
25
|
+
page_start: int
|
|
26
|
+
page_end: int
|
|
27
|
+
heading_path: str
|
|
28
|
+
token_count: int
|
|
29
|
+
block_ids: list[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def tokens(text: str) -> list[str]:
|
|
33
|
+
return text.split()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def detect_heading(text: str, current: str) -> str:
|
|
37
|
+
for line in text.splitlines():
|
|
38
|
+
stripped = line.strip()
|
|
39
|
+
if stripped and len(stripped) < 120 and _HEADING_RE.match(stripped):
|
|
40
|
+
return stripped
|
|
41
|
+
return current
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def chunk_pages(pages, chunk_size: int = 500, overlap: int = 75) -> list[Chunk]:
|
|
45
|
+
if chunk_size <= 0:
|
|
46
|
+
raise ValueError("chunk_size must be positive")
|
|
47
|
+
overlap = max(0, min(overlap, chunk_size - 1))
|
|
48
|
+
chunks: list[Chunk] = []
|
|
49
|
+
heading = ""
|
|
50
|
+
for page in pages:
|
|
51
|
+
heading = detect_heading(page.text, heading)
|
|
52
|
+
paragraphs = [p.strip() for p in re.split(r"\n\s*\n|(?<=\.)\s*\n", page.text) if p.strip()]
|
|
53
|
+
if not paragraphs and page.text.strip():
|
|
54
|
+
paragraphs = [page.text.strip()]
|
|
55
|
+
buffer: list[str] = []
|
|
56
|
+
for para in paragraphs:
|
|
57
|
+
words = tokens(para)
|
|
58
|
+
if len(words) > chunk_size:
|
|
59
|
+
if buffer:
|
|
60
|
+
text = " ".join(buffer)
|
|
61
|
+
chunks.append(Chunk(text, page.page_number, page.page_number, heading, len(tokens(text))))
|
|
62
|
+
buffer = []
|
|
63
|
+
step = chunk_size - overlap
|
|
64
|
+
for start in range(0, len(words), step):
|
|
65
|
+
part = words[start : start + chunk_size]
|
|
66
|
+
if part:
|
|
67
|
+
chunks.append(Chunk(" ".join(part), page.page_number, page.page_number, heading, len(part)))
|
|
68
|
+
if start + chunk_size >= len(words):
|
|
69
|
+
break
|
|
70
|
+
elif len(buffer) + len(words) > chunk_size and buffer:
|
|
71
|
+
text = " ".join(buffer)
|
|
72
|
+
chunks.append(Chunk(text, page.page_number, page.page_number, heading, len(tokens(text))))
|
|
73
|
+
buffer = buffer[-overlap:] if overlap else []
|
|
74
|
+
buffer.extend(words)
|
|
75
|
+
else:
|
|
76
|
+
buffer.extend(words)
|
|
77
|
+
if buffer:
|
|
78
|
+
text = " ".join(buffer)
|
|
79
|
+
chunks.append(Chunk(text, page.page_number, page.page_number, heading, len(tokens(text))))
|
|
80
|
+
return chunks
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_chunks_from_blocks(
|
|
84
|
+
blocks: list[tuple[str, ParsedBlock]],
|
|
85
|
+
chunk_size: int = 500,
|
|
86
|
+
overlap: int = 75,
|
|
87
|
+
) -> list[Chunk]:
|
|
88
|
+
"""Heading-aware chunking over structured blocks."""
|
|
89
|
+
if chunk_size <= 0:
|
|
90
|
+
raise ValueError("chunk_size must be positive")
|
|
91
|
+
overlap = max(0, min(overlap, chunk_size - 1))
|
|
92
|
+
|
|
93
|
+
chunks: list[Chunk] = []
|
|
94
|
+
heading_stack: list[tuple[int, str]] = []
|
|
95
|
+
buffer_words: list[str] = []
|
|
96
|
+
buffer_blocks: list[str] = []
|
|
97
|
+
buffer_pages: list[int] = []
|
|
98
|
+
# Image block ids seen since the last flush that haven't yet been attached
|
|
99
|
+
# to a chunk (e.g. an image appears before any text on a fresh page).
|
|
100
|
+
pending_images: list[tuple[int, str]] = []
|
|
101
|
+
|
|
102
|
+
def heading_path() -> str:
|
|
103
|
+
return " > ".join(text for _, text in heading_stack)
|
|
104
|
+
|
|
105
|
+
def flush() -> None:
|
|
106
|
+
nonlocal buffer_words, buffer_blocks, buffer_pages
|
|
107
|
+
if buffer_words:
|
|
108
|
+
text = " ".join(buffer_words)
|
|
109
|
+
chunks.append(
|
|
110
|
+
Chunk(
|
|
111
|
+
text,
|
|
112
|
+
min(buffer_pages),
|
|
113
|
+
max(buffer_pages),
|
|
114
|
+
heading_path(),
|
|
115
|
+
len(buffer_words),
|
|
116
|
+
list(dict.fromkeys(buffer_blocks)),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
buffer_words = []
|
|
120
|
+
buffer_blocks = []
|
|
121
|
+
buffer_pages = []
|
|
122
|
+
|
|
123
|
+
def attach_pending_images(page_number: int, target_block_ids: list[str]) -> None:
|
|
124
|
+
"""Attach queued image block ids for this page onto a chunk's blocks."""
|
|
125
|
+
nonlocal pending_images
|
|
126
|
+
if not pending_images:
|
|
127
|
+
return
|
|
128
|
+
remaining = []
|
|
129
|
+
for pg, image_block_id in pending_images:
|
|
130
|
+
if pg == page_number:
|
|
131
|
+
target_block_ids.append(image_block_id)
|
|
132
|
+
else:
|
|
133
|
+
remaining.append((pg, image_block_id))
|
|
134
|
+
pending_images = remaining
|
|
135
|
+
|
|
136
|
+
for block_id, block in blocks:
|
|
137
|
+
if block.block_type == "image":
|
|
138
|
+
if buffer_words and buffer_pages and buffer_pages[-1] == block.page_number:
|
|
139
|
+
# A chunk is actively being assembled for this page — attach directly
|
|
140
|
+
# so the image travels with its surrounding text.
|
|
141
|
+
buffer_blocks.append(block_id)
|
|
142
|
+
else:
|
|
143
|
+
# No open chunk on this page yet — queue it for the next one.
|
|
144
|
+
pending_images.append((block.page_number, block_id))
|
|
145
|
+
continue
|
|
146
|
+
if buffer_pages and block.page_number != buffer_pages[-1]:
|
|
147
|
+
# Keep citations page-precise: do not merge chunks across pages.
|
|
148
|
+
flush()
|
|
149
|
+
if block.block_type == "heading":
|
|
150
|
+
flush()
|
|
151
|
+
level = block.heading_level or 6
|
|
152
|
+
while heading_stack and heading_stack[-1][0] >= level:
|
|
153
|
+
heading_stack.pop()
|
|
154
|
+
heading_stack.append((level, block.text))
|
|
155
|
+
continue
|
|
156
|
+
if block.block_type == "table":
|
|
157
|
+
flush()
|
|
158
|
+
words = tokens(block.text)
|
|
159
|
+
if not words:
|
|
160
|
+
continue
|
|
161
|
+
if len(words) > chunk_size:
|
|
162
|
+
flush()
|
|
163
|
+
step = chunk_size - overlap
|
|
164
|
+
first_part = True
|
|
165
|
+
for start in range(0, len(words), step):
|
|
166
|
+
part = words[start : start + chunk_size]
|
|
167
|
+
if part:
|
|
168
|
+
part_block_ids = [block_id]
|
|
169
|
+
if first_part:
|
|
170
|
+
attach_pending_images(block.page_number, part_block_ids)
|
|
171
|
+
first_part = False
|
|
172
|
+
chunks.append(
|
|
173
|
+
Chunk(
|
|
174
|
+
" ".join(part),
|
|
175
|
+
block.page_number,
|
|
176
|
+
block.page_number,
|
|
177
|
+
heading_path(),
|
|
178
|
+
len(part),
|
|
179
|
+
part_block_ids,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
if start + chunk_size >= len(words):
|
|
183
|
+
break
|
|
184
|
+
continue
|
|
185
|
+
if buffer_words and len(buffer_words) + len(words) > chunk_size:
|
|
186
|
+
carry = buffer_words[-overlap:] if overlap else []
|
|
187
|
+
flush()
|
|
188
|
+
buffer_words.extend(carry)
|
|
189
|
+
starting_new_buffer = not buffer_words
|
|
190
|
+
buffer_words.extend(words)
|
|
191
|
+
buffer_blocks.append(block_id)
|
|
192
|
+
buffer_pages.append(block.page_number)
|
|
193
|
+
if starting_new_buffer:
|
|
194
|
+
attach_pending_images(block.page_number, buffer_blocks)
|
|
195
|
+
flush()
|
|
196
|
+
if pending_images:
|
|
197
|
+
# Trailing images with no following text on their page: attach to the
|
|
198
|
+
# last chunk that already covers that page, if any.
|
|
199
|
+
for page_number, image_block_id in pending_images:
|
|
200
|
+
for chunk in reversed(chunks):
|
|
201
|
+
if chunk.page_start <= page_number <= chunk.page_end:
|
|
202
|
+
chunk.block_ids.append(image_block_id)
|
|
203
|
+
break
|
|
204
|
+
return chunks
|