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.
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ *.pyc
5
+ *.vera
6
+ dist/
7
+ dist-electron/
8
+ release/
9
+ build/
10
+ node_modules/
11
+ *.egg-info/
12
+ examples/*.pdf
13
+ site/
@@ -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