viparse 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.
- viparse/__init__.py +106 -0
- viparse/api.py +250 -0
- viparse/cache.py +120 -0
- viparse/chunk.py +128 -0
- viparse/cli.py +203 -0
- viparse/config.py +175 -0
- viparse/detect.py +107 -0
- viparse/engines/__init__.py +6 -0
- viparse/engines/_shared.py +25 -0
- viparse/engines/docx.py +164 -0
- viparse/engines/legacy.py +136 -0
- viparse/engines/ocr.py +125 -0
- viparse/engines/pdf.py +113 -0
- viparse/engines/xlsx.py +154 -0
- viparse/errors.py +53 -0
- viparse/integrations/__init__.py +12 -0
- viparse/integrations/_common.py +51 -0
- viparse/integrations/langchain.py +38 -0
- viparse/integrations/llamaindex.py +37 -0
- viparse/model.py +178 -0
- viparse/normalize/__init__.py +7 -0
- viparse/normalize/cleanup.py +52 -0
- viparse/normalize/detector.py +131 -0
- viparse/normalize/encodings.py +30 -0
- viparse/normalize/frequency.py +60 -0
- viparse/normalize/normalizer.py +137 -0
- viparse/normalize/tables.py +91 -0
- viparse/normalize/tcvn3.py +44 -0
- viparse/normalize/viscii.py +147 -0
- viparse/normalize/vni.py +37 -0
- viparse/observability.py +39 -0
- viparse/options.py +60 -0
- viparse/pipeline.py +238 -0
- viparse/protocols.py +88 -0
- viparse/registry.py +51 -0
- viparse/safety.py +61 -0
- viparse/structure/__init__.py +7 -0
- viparse/structure/renderer.py +130 -0
- viparse-0.1.0.dist-info/METADATA +83 -0
- viparse-0.1.0.dist-info/RECORD +42 -0
- viparse-0.1.0.dist-info/WHEEL +4 -0
- viparse-0.1.0.dist-info/entry_points.txt +2 -0
viparse/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""viparse — a Vietnamese-first document loader for RAG."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from viparse.api import load, load_batch
|
|
6
|
+
from viparse.cache import Cache, DiskCache, MemoryCache
|
|
7
|
+
from viparse.chunk import ChunkOptions, chunk_document
|
|
8
|
+
from viparse.config import Settings, load_settings
|
|
9
|
+
from viparse.detect import DetectedFormat, detect_format
|
|
10
|
+
from viparse.engines.docx import DocxEngine
|
|
11
|
+
from viparse.engines.legacy import LegacyOfficeEngine
|
|
12
|
+
from viparse.engines.ocr import OcrEngine
|
|
13
|
+
from viparse.engines.pdf import PdfEngine
|
|
14
|
+
from viparse.engines.xlsx import XlsxEngine
|
|
15
|
+
from viparse.errors import (
|
|
16
|
+
ConfigError,
|
|
17
|
+
EncodingError,
|
|
18
|
+
EngineUnavailable,
|
|
19
|
+
ExtractionError,
|
|
20
|
+
MissingDependency,
|
|
21
|
+
UnsafeInput,
|
|
22
|
+
UnsupportedFormat,
|
|
23
|
+
ViparseError,
|
|
24
|
+
)
|
|
25
|
+
from viparse.integrations import to_langchain_documents, to_llamaindex_documents
|
|
26
|
+
from viparse.model import (
|
|
27
|
+
SCHEMA_VERSION,
|
|
28
|
+
Block,
|
|
29
|
+
Chunk,
|
|
30
|
+
Document,
|
|
31
|
+
DocumentMetadata,
|
|
32
|
+
Heading,
|
|
33
|
+
NormalizedDoc,
|
|
34
|
+
Paragraph,
|
|
35
|
+
RawExtraction,
|
|
36
|
+
Table,
|
|
37
|
+
)
|
|
38
|
+
from viparse.normalize.normalizer import VietnameseNormalizer
|
|
39
|
+
from viparse.observability import MetricsHook, PipelineMetrics
|
|
40
|
+
from viparse.options import LoadOptions, NormalizeForm, OutputFormat
|
|
41
|
+
from viparse.pipeline import Pipeline
|
|
42
|
+
from viparse.protocols import (
|
|
43
|
+
DEFAULT_PRIORITY,
|
|
44
|
+
Engine,
|
|
45
|
+
Normalizer,
|
|
46
|
+
Renderer,
|
|
47
|
+
Source,
|
|
48
|
+
)
|
|
49
|
+
from viparse.registry import EngineRegistry
|
|
50
|
+
from viparse.structure import DocumentRenderer
|
|
51
|
+
|
|
52
|
+
__version__ = "0.1.0"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"DEFAULT_PRIORITY",
|
|
56
|
+
"Block",
|
|
57
|
+
"Cache",
|
|
58
|
+
"Chunk",
|
|
59
|
+
"ChunkOptions",
|
|
60
|
+
"chunk_document",
|
|
61
|
+
"ConfigError",
|
|
62
|
+
"DetectedFormat",
|
|
63
|
+
"DiskCache",
|
|
64
|
+
"MemoryCache",
|
|
65
|
+
"DocumentRenderer",
|
|
66
|
+
"DocxEngine",
|
|
67
|
+
"Document",
|
|
68
|
+
"DocumentMetadata",
|
|
69
|
+
"EncodingError",
|
|
70
|
+
"Engine",
|
|
71
|
+
"EngineRegistry",
|
|
72
|
+
"EngineUnavailable",
|
|
73
|
+
"ExtractionError",
|
|
74
|
+
"Heading",
|
|
75
|
+
"LegacyOfficeEngine",
|
|
76
|
+
"LoadOptions",
|
|
77
|
+
"load",
|
|
78
|
+
"load_batch",
|
|
79
|
+
"MetricsHook",
|
|
80
|
+
"MissingDependency",
|
|
81
|
+
"NormalizeForm",
|
|
82
|
+
"NormalizedDoc",
|
|
83
|
+
"Normalizer",
|
|
84
|
+
"OcrEngine",
|
|
85
|
+
"OutputFormat",
|
|
86
|
+
"Paragraph",
|
|
87
|
+
"PdfEngine",
|
|
88
|
+
"Pipeline",
|
|
89
|
+
"PipelineMetrics",
|
|
90
|
+
"RawExtraction",
|
|
91
|
+
"Renderer",
|
|
92
|
+
"SCHEMA_VERSION",
|
|
93
|
+
"Settings",
|
|
94
|
+
"Source",
|
|
95
|
+
"Table",
|
|
96
|
+
"load_settings",
|
|
97
|
+
"to_langchain_documents",
|
|
98
|
+
"to_llamaindex_documents",
|
|
99
|
+
"UnsafeInput",
|
|
100
|
+
"UnsupportedFormat",
|
|
101
|
+
"VietnameseNormalizer",
|
|
102
|
+
"ViparseError",
|
|
103
|
+
"XlsxEngine",
|
|
104
|
+
"__version__",
|
|
105
|
+
"detect_format",
|
|
106
|
+
]
|
viparse/api.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""The public :func:`load` / :func:`load_batch` API — the surface most callers use.
|
|
2
|
+
|
|
3
|
+
These are thin ergonomic wrappers over :class:`~viparse.pipeline.Pipeline`, assembled
|
|
4
|
+
with the built-in engines, the Vietnamese normalizer, and the document renderer. The
|
|
5
|
+
keyword-only parameters map onto :class:`~viparse.options.LoadOptions`; keeping them
|
|
6
|
+
keyword-only lets the surface grow (SemVer-safely) without breaking callers.
|
|
7
|
+
|
|
8
|
+
``load`` returns a ``list[Document]`` — one entry today, but the list shape reserves
|
|
9
|
+
room for sources that fan out into several documents (e.g. one per spreadsheet sheet)
|
|
10
|
+
without a signature change.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections import deque
|
|
16
|
+
from collections.abc import Iterable, Iterator
|
|
17
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
18
|
+
|
|
19
|
+
from viparse.cache import Cache, cache_key
|
|
20
|
+
from viparse.chunk import ChunkOptions
|
|
21
|
+
from viparse.config import UNSET, Settings, _Unset, load_settings
|
|
22
|
+
from viparse.engines.docx import DocxEngine
|
|
23
|
+
from viparse.engines.legacy import LegacyOfficeEngine
|
|
24
|
+
from viparse.engines.ocr import OcrEngine
|
|
25
|
+
from viparse.engines.pdf import PdfEngine
|
|
26
|
+
from viparse.engines.xlsx import XlsxEngine
|
|
27
|
+
from viparse.errors import ViparseError
|
|
28
|
+
from viparse.model import Document, NormalizedDoc
|
|
29
|
+
from viparse.normalize.normalizer import VietnameseNormalizer
|
|
30
|
+
from viparse.options import LoadOptions, NormalizeForm, OutputFormat
|
|
31
|
+
from viparse.pipeline import Pipeline
|
|
32
|
+
from viparse.protocols import Engine, Source
|
|
33
|
+
from viparse.registry import EngineRegistry
|
|
34
|
+
from viparse.structure import DocumentRenderer
|
|
35
|
+
|
|
36
|
+
# Content type for a batch error Document whose real type was never determined (matches
|
|
37
|
+
# the pipeline's own unknown-type sentinel).
|
|
38
|
+
_UNKNOWN_CONTENT_TYPE = "application/octet-stream"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _default_engines() -> list[Engine]:
|
|
42
|
+
"""The engines registered by default.
|
|
43
|
+
|
|
44
|
+
Each is a thin adapter whose heavy parse library is imported lazily at extraction
|
|
45
|
+
time, so listing one here never pulls its dependency at import — ``core`` stays light.
|
|
46
|
+
"""
|
|
47
|
+
return [DocxEngine(), XlsxEngine(), PdfEngine(), OcrEngine(), LegacyOfficeEngine()]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_pipeline() -> Pipeline:
|
|
51
|
+
"""Assemble the default pipeline: built-in engines → normalizer → renderer."""
|
|
52
|
+
registry = EngineRegistry()
|
|
53
|
+
for engine in _default_engines():
|
|
54
|
+
registry.register(engine)
|
|
55
|
+
return Pipeline(registry, VietnameseNormalizer(), DocumentRenderer())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_options(
|
|
59
|
+
settings: Settings | None,
|
|
60
|
+
output: OutputFormat | _Unset,
|
|
61
|
+
encoding: str | None | _Unset,
|
|
62
|
+
ocr: bool | None | _Unset,
|
|
63
|
+
normalize: NormalizeForm | _Unset,
|
|
64
|
+
max_bytes: int | _Unset,
|
|
65
|
+
chunk: ChunkOptions | None,
|
|
66
|
+
) -> LoadOptions:
|
|
67
|
+
"""Layer the public keyword parameters over the resolved :class:`Settings`.
|
|
68
|
+
|
|
69
|
+
An argument left as :data:`~viparse.config.UNSET` falls back to ``settings`` (which,
|
|
70
|
+
when not supplied, is resolved from ``VIPARSE_*`` env vars and ``viparse.toml``); an
|
|
71
|
+
explicit argument always wins. ``chunk`` is per-call only and never layered.
|
|
72
|
+
"""
|
|
73
|
+
base = settings if settings is not None else load_settings()
|
|
74
|
+
return LoadOptions(
|
|
75
|
+
fmt=base.output if output is UNSET else output,
|
|
76
|
+
encoding=base.encoding if encoding is UNSET else encoding,
|
|
77
|
+
ocr=base.ocr if ocr is UNSET else ocr,
|
|
78
|
+
normalize_form=base.normalize if normalize is UNSET else normalize,
|
|
79
|
+
max_bytes=base.max_bytes if max_bytes is UNSET else max_bytes,
|
|
80
|
+
chunk=chunk,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load(
|
|
85
|
+
source: Source,
|
|
86
|
+
*,
|
|
87
|
+
output: OutputFormat | _Unset = UNSET,
|
|
88
|
+
encoding: str | None | _Unset = UNSET,
|
|
89
|
+
ocr: bool | None | _Unset = UNSET,
|
|
90
|
+
normalize: NormalizeForm | _Unset = UNSET,
|
|
91
|
+
max_bytes: int | _Unset = UNSET,
|
|
92
|
+
cache: Cache | None = None,
|
|
93
|
+
chunk: ChunkOptions | None = None,
|
|
94
|
+
settings: Settings | None = None,
|
|
95
|
+
) -> list[Document]:
|
|
96
|
+
"""Parse ``source`` into a list of Unicode-**NFC** :class:`Document` objects.
|
|
97
|
+
|
|
98
|
+
The ``output`` / ``encoding`` / ``ocr`` / ``normalize`` / ``max_bytes`` options are
|
|
99
|
+
**layered** (SPEC-5 E5.4): an argument left unset falls back to ``VIPARSE_*`` env vars,
|
|
100
|
+
then ``viparse.toml``, then the built-in default; an explicit argument always wins.
|
|
101
|
+
|
|
102
|
+
:param source: path to the document (``str`` or :class:`~pathlib.Path`).
|
|
103
|
+
:param output: rendered output format — ``"markdown"`` (default), ``"text"``, or ``"json"``.
|
|
104
|
+
:param encoding: force a legacy source encoding (e.g. ``"tcvn3"``), or ``"auto"`` to
|
|
105
|
+
opt into content-based detection when the source carries no font signal.
|
|
106
|
+
:param ocr: force OCR on/off; the router otherwise decides from the file.
|
|
107
|
+
:param normalize: target Unicode normalization form (default ``"NFC"``).
|
|
108
|
+
:param max_bytes: reject an input larger than this many bytes (default 100 MiB).
|
|
109
|
+
:param cache: optional content-hash :class:`~viparse.cache.Cache`; a cache hit skips
|
|
110
|
+
re-parsing an unchanged file (SPEC-7 E7.3).
|
|
111
|
+
:param chunk: optional :class:`~viparse.chunk.ChunkOptions`; when given, the returned
|
|
112
|
+
Document's ``chunks`` are populated with retrieval-sized pieces (SPEC-4 E4.2).
|
|
113
|
+
:param settings: pre-resolved :class:`~viparse.config.Settings` to layer under the
|
|
114
|
+
arguments; when omitted, settings are resolved from the environment and config file
|
|
115
|
+
**on every call** (a ``viparse.toml`` stat/read + an env scan). When loading many
|
|
116
|
+
files, resolve once with :func:`~viparse.load_settings` and pass ``settings=`` here
|
|
117
|
+
(or use :func:`load_batch`, which resolves once for the whole batch).
|
|
118
|
+
"""
|
|
119
|
+
options = _resolve_options(settings, output, encoding, ocr, normalize, max_bytes, chunk)
|
|
120
|
+
return [_load_one(_build_pipeline(), source, options, cache)]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_one(
|
|
124
|
+
pipeline: Pipeline, source: Source, options: LoadOptions, cache: Cache | None
|
|
125
|
+
) -> Document:
|
|
126
|
+
"""Run one source through the pipeline, consulting/populating ``cache`` if given."""
|
|
127
|
+
if cache is None:
|
|
128
|
+
return pipeline.run(source, options)
|
|
129
|
+
key = cache_key(source, options)
|
|
130
|
+
cached = cache.get(key)
|
|
131
|
+
if cached is not None:
|
|
132
|
+
return cached
|
|
133
|
+
document = pipeline.run(source, options)
|
|
134
|
+
cache.set(key, document)
|
|
135
|
+
return document
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _error_document(source: Source, error: Exception, fmt: OutputFormat) -> Document:
|
|
139
|
+
"""A best-effort Document for a source that failed to load in a batch.
|
|
140
|
+
|
|
141
|
+
Rendered through the normal renderer so its ``text`` is valid for the requested format
|
|
142
|
+
(e.g. a parseable JSON payload under ``output="json"``, not an empty string), with the
|
|
143
|
+
failure in ``metadata.warnings`` and the unknown-content sentinel as the content type.
|
|
144
|
+
"""
|
|
145
|
+
normalized = NormalizedDoc(
|
|
146
|
+
source=str(source),
|
|
147
|
+
content_type=_UNKNOWN_CONTENT_TYPE,
|
|
148
|
+
text="",
|
|
149
|
+
warnings=[f"failed to load: {error}"],
|
|
150
|
+
)
|
|
151
|
+
return DocumentRenderer().render(normalized, fmt)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _batch_load(
|
|
155
|
+
pipeline: Pipeline, source: Source, options: LoadOptions, cache: Cache | None
|
|
156
|
+
) -> Document:
|
|
157
|
+
"""Load one source for a batch, isolating a per-source *failure* into an error Document.
|
|
158
|
+
|
|
159
|
+
Only extraction/format failures (:class:`~viparse.errors.ViparseError`) and file-access
|
|
160
|
+
errors (:class:`OSError`) are isolated so one bad file can't sink the batch. Programming
|
|
161
|
+
bugs (``TypeError`` etc.) deliberately propagate — matching the pipeline's rule that
|
|
162
|
+
they must never be silently downgraded to a warning.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
return _load_one(pipeline, source, options, cache)
|
|
166
|
+
except (ViparseError, OSError) as error:
|
|
167
|
+
return _error_document(source, error, options.fmt)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def load_batch(
|
|
171
|
+
sources: Iterable[Source],
|
|
172
|
+
*,
|
|
173
|
+
output: OutputFormat | _Unset = UNSET,
|
|
174
|
+
encoding: str | None | _Unset = UNSET,
|
|
175
|
+
ocr: bool | None | _Unset = UNSET,
|
|
176
|
+
normalize: NormalizeForm | _Unset = UNSET,
|
|
177
|
+
max_bytes: int | _Unset = UNSET,
|
|
178
|
+
cache: Cache | None = None,
|
|
179
|
+
workers: int | None = None,
|
|
180
|
+
chunk: ChunkOptions | None = None,
|
|
181
|
+
settings: Settings | None = None,
|
|
182
|
+
) -> Iterator[list[Document]]:
|
|
183
|
+
"""Lazily load each source, yielding its result list **in input order**.
|
|
184
|
+
|
|
185
|
+
A generator (not a materialized list), so results stream out as they're ready and the
|
|
186
|
+
caller controls consumption. Unlike :func:`load`, a batch **isolates failures**: a
|
|
187
|
+
source that errors yields a best-effort Document recording the failure (its
|
|
188
|
+
``metadata.warnings``) instead of sinking the whole batch (SPEC-7 T7.2.3).
|
|
189
|
+
|
|
190
|
+
``workers`` (>1) parses up to that many sources concurrently on a thread pool —
|
|
191
|
+
extraction is largely IO / subprocess bound (LibreOffice, Tesseract, file reads). Only
|
|
192
|
+
``workers`` sources are ever in flight at once (backpressure), and output order still
|
|
193
|
+
matches the input. ``None``/1 runs sequentially. Abandoning the iterator early waits for
|
|
194
|
+
the (at most ``workers``) in-flight parses to finish before the pool shuts down.
|
|
195
|
+
|
|
196
|
+
``chunk`` behaves as in :func:`load`: when set, each result Document's ``chunks`` are
|
|
197
|
+
populated with retrieval-sized pieces. ``output`` / ``encoding`` / ``ocr`` / ``normalize`` /
|
|
198
|
+
``max_bytes`` are layered over ``settings`` exactly as in :func:`load`.
|
|
199
|
+
|
|
200
|
+
Option resolution (and any :class:`~viparse.errors.ConfigError` from bad env/config) happens
|
|
201
|
+
**eagerly at call time**, not on first iteration — so a misconfiguration surfaces to the
|
|
202
|
+
caller here rather than mid-stream, past the per-source error isolation.
|
|
203
|
+
"""
|
|
204
|
+
options = _resolve_options(settings, output, encoding, ocr, normalize, max_bytes, chunk)
|
|
205
|
+
pipeline = _build_pipeline()
|
|
206
|
+
return _iter_batch(pipeline, sources, options, cache, workers)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _iter_batch(
|
|
210
|
+
pipeline: Pipeline,
|
|
211
|
+
sources: Iterable[Source],
|
|
212
|
+
options: LoadOptions,
|
|
213
|
+
cache: Cache | None,
|
|
214
|
+
workers: int | None,
|
|
215
|
+
) -> Iterator[list[Document]]:
|
|
216
|
+
"""The lazy per-source generator behind :func:`load_batch` (options already resolved)."""
|
|
217
|
+
if workers is None or workers <= 1:
|
|
218
|
+
for source in sources:
|
|
219
|
+
yield [_batch_load(pipeline, source, options, cache)]
|
|
220
|
+
return
|
|
221
|
+
yield from _parallel_load_batch(pipeline, sources, options, cache, workers)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _parallel_load_batch(
|
|
225
|
+
pipeline: Pipeline,
|
|
226
|
+
sources: Iterable[Source],
|
|
227
|
+
options: LoadOptions,
|
|
228
|
+
cache: Cache | None,
|
|
229
|
+
workers: int,
|
|
230
|
+
) -> Iterator[list[Document]]:
|
|
231
|
+
"""Parse sources on a bounded thread pool, yielding results in input order."""
|
|
232
|
+
pending: deque[Future[Document]] = deque()
|
|
233
|
+
source_iter = iter(sources)
|
|
234
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
235
|
+
|
|
236
|
+
def submit_next() -> bool:
|
|
237
|
+
try:
|
|
238
|
+
source = next(source_iter)
|
|
239
|
+
except StopIteration:
|
|
240
|
+
return False
|
|
241
|
+
pending.append(executor.submit(_batch_load, pipeline, source, options, cache))
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
for _ in range(workers): # prime the in-flight window
|
|
245
|
+
if not submit_next():
|
|
246
|
+
break
|
|
247
|
+
while pending:
|
|
248
|
+
document = pending.popleft().result() # oldest first → input order
|
|
249
|
+
submit_next() # keep the window full as each result drains
|
|
250
|
+
yield [document]
|
viparse/cache.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Content-hash caching so unchanged files are not re-parsed (SPEC-7 E7.3).
|
|
2
|
+
|
|
3
|
+
A cache is keyed by the file's content hash *plus* the options that affect the output
|
|
4
|
+
(format, encoding, OCR, normalization) and the :data:`~viparse.model.SCHEMA_VERSION`, so
|
|
5
|
+
a changed file, different options, or a new pipeline version all miss the cache and never
|
|
6
|
+
serve a stale result. Caching is opt-in — pass a :class:`Cache` to :func:`viparse.load`.
|
|
7
|
+
|
|
8
|
+
Two stores ship: :class:`MemoryCache` (per-process) and :class:`DiskCache` (persists
|
|
9
|
+
across runs). Both satisfy the small :class:`Cache` Protocol, so callers can supply their
|
|
10
|
+
own (Redis, S3, …).
|
|
11
|
+
|
|
12
|
+
.. note::
|
|
13
|
+
The key is computed by hashing the file *before* the pipeline re-reads it to parse, so
|
|
14
|
+
rewriting a file in place *during* a load is a TOCTOU race that can cache the new bytes
|
|
15
|
+
under the old hash. Don't mutate files in place while loading them; write a new file
|
|
16
|
+
(atomic rename) instead.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import os
|
|
23
|
+
import pickle
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Protocol, runtime_checkable
|
|
27
|
+
|
|
28
|
+
from viparse.model import SCHEMA_VERSION, Document
|
|
29
|
+
from viparse.options import LoadOptions
|
|
30
|
+
from viparse.protocols import Source
|
|
31
|
+
|
|
32
|
+
_HASH_CHUNK = 1024 * 1024 # hash the file in 1 MiB chunks (bounded memory)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class Cache(Protocol):
|
|
37
|
+
"""A content-addressed store of parsed :class:`Document` results."""
|
|
38
|
+
|
|
39
|
+
def get(self, key: str) -> Document | None:
|
|
40
|
+
"""Return the cached document for ``key``, or ``None`` on a miss."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def set(self, key: str, document: Document) -> None:
|
|
44
|
+
"""Store ``document`` under ``key``."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MemoryCache:
|
|
49
|
+
"""A simple in-process cache backed by a dict."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._store: dict[str, Document] = {}
|
|
53
|
+
|
|
54
|
+
def get(self, key: str) -> Document | None:
|
|
55
|
+
return self._store.get(key)
|
|
56
|
+
|
|
57
|
+
def set(self, key: str, document: Document) -> None:
|
|
58
|
+
self._store[key] = document
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DiskCache:
|
|
62
|
+
"""A cache that persists pickled documents under a directory, one file per key."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, directory: Source) -> None:
|
|
65
|
+
self._dir = Path(directory)
|
|
66
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
def _path(self, key: str) -> Path:
|
|
69
|
+
# Hash the key into the filename so an arbitrary key can never traverse out of the
|
|
70
|
+
# cache directory, and any string is a valid (fixed-length, filesystem-safe) name.
|
|
71
|
+
name = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
72
|
+
return self._dir / f"{name}.pickle"
|
|
73
|
+
|
|
74
|
+
def get(self, key: str) -> Document | None:
|
|
75
|
+
try:
|
|
76
|
+
with self._path(key).open("rb") as fh:
|
|
77
|
+
# Trusted input: the cache directory holds only this library's own writes.
|
|
78
|
+
document: Document = pickle.load(fh)
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
return None # a plain miss
|
|
81
|
+
except Exception: # noqa: BLE001 — any corrupt/unreadable entry is treated as a miss
|
|
82
|
+
return None
|
|
83
|
+
return document
|
|
84
|
+
|
|
85
|
+
def set(self, key: str, document: Document) -> None:
|
|
86
|
+
# Write to a temp file in the same directory, then atomically rename into place, so
|
|
87
|
+
# a crash or a concurrent writer never leaves a half-written entry to be read back.
|
|
88
|
+
fd, tmp = tempfile.mkstemp(dir=self._dir, suffix=".tmp")
|
|
89
|
+
with os.fdopen(fd, "wb") as fh:
|
|
90
|
+
pickle.dump(document, fh)
|
|
91
|
+
os.replace(tmp, self._path(key))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cache_key(source: Source, options: LoadOptions) -> str:
|
|
95
|
+
"""A stable key from the file's content hash, its path, and the output-affecting options.
|
|
96
|
+
|
|
97
|
+
The path is part of the key so a hit only ever returns the document parsed from *that*
|
|
98
|
+
source (its ``metadata.source`` stays correct — two byte-identical files never share an
|
|
99
|
+
entry). The options are fingerprinted with :func:`repr` of a tuple so ``None`` and the
|
|
100
|
+
string ``"None"`` (and any field containing a delimiter) can never collide. ``max_bytes``
|
|
101
|
+
and ``strict`` are omitted because they do not change the parsed output; ``chunk`` is
|
|
102
|
+
included because it changes the document (its ``chunks``), and its frozen ``repr`` is stable.
|
|
103
|
+
"""
|
|
104
|
+
digest = hashlib.sha256()
|
|
105
|
+
with Path(source).open("rb") as fh:
|
|
106
|
+
for block in iter(lambda: fh.read(_HASH_CHUNK), b""):
|
|
107
|
+
digest.update(block)
|
|
108
|
+
fingerprint = repr(
|
|
109
|
+
(
|
|
110
|
+
str(source),
|
|
111
|
+
SCHEMA_VERSION,
|
|
112
|
+
options.fmt,
|
|
113
|
+
options.encoding,
|
|
114
|
+
options.ocr,
|
|
115
|
+
options.normalize_form,
|
|
116
|
+
options.chunk,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
digest.update(fingerprint.encode("utf-8"))
|
|
120
|
+
return digest.hexdigest()
|
viparse/chunk.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""RAG chunking: split a normalized document into retrieval-sized pieces (SPEC-4 E4.2).
|
|
2
|
+
|
|
3
|
+
Chunking works on the **block structure** (headings / paragraphs / table rows), not the
|
|
4
|
+
flat text, so it can:
|
|
5
|
+
|
|
6
|
+
- keep every chunk within a single section — any section change (a heading, or an empty
|
|
7
|
+
heading that merely resets the section) starts a new chunk, so a chunk's ``section``
|
|
8
|
+
metadata is unambiguous (it never straddles a section boundary);
|
|
9
|
+
- **never split a table row** — each row is an atomic unit, and a large table is split at
|
|
10
|
+
row boundaries;
|
|
11
|
+
- carry per-chunk metadata (``section`` and inherited ``page`` / ``sheet``) plus the
|
|
12
|
+
chunk's ordinal :attr:`~viparse.model.Chunk.index` for downstream provenance.
|
|
13
|
+
|
|
14
|
+
Token counts are **approximate and tiktoken-free** (whitespace-delimited words), which is
|
|
15
|
+
enough to bound chunk size without pinning to any one model's tokenizer. A single block
|
|
16
|
+
larger than the target is emitted whole (sub-splitting long paragraphs is a future
|
|
17
|
+
refinement); table rows are always kept intact regardless of size.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
from viparse.model import Block, Chunk, Heading, NormalizedDoc, Table, blocks_of
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class ChunkOptions:
|
|
30
|
+
"""Chunking knobs: target size and overlap, both in approximate tokens (words)."""
|
|
31
|
+
|
|
32
|
+
max_tokens: int = 512
|
|
33
|
+
overlap_tokens: int = 64
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def estimate_tokens(text: str) -> int:
|
|
37
|
+
"""Approximate token count for ``text`` (tiktoken-free: whitespace-delimited words)."""
|
|
38
|
+
return len(text.split())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class _Unit:
|
|
43
|
+
"""An atomic chunkable piece — a heading, a paragraph, or a single table row."""
|
|
44
|
+
|
|
45
|
+
text: str
|
|
46
|
+
section: str
|
|
47
|
+
is_heading: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def chunk_document(document: NormalizedDoc, options: ChunkOptions) -> list[Chunk]:
|
|
51
|
+
"""Split ``document`` into overlapping, single-section :class:`~viparse.model.Chunk`s."""
|
|
52
|
+
units = list(_iter_units(blocks_of(document)))
|
|
53
|
+
if not units:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
chunks: list[Chunk] = []
|
|
57
|
+
start = 0
|
|
58
|
+
count = len(units)
|
|
59
|
+
while True: # always terminates via the `end >= count` break below (count >= 1 here)
|
|
60
|
+
end = start
|
|
61
|
+
tokens = 0
|
|
62
|
+
while end < count:
|
|
63
|
+
if end > start and _crosses_section(units, start, end):
|
|
64
|
+
break # a new section starts here — never split one across chunks
|
|
65
|
+
unit_tokens = estimate_tokens(units[end].text)
|
|
66
|
+
if end > start and tokens + unit_tokens > options.max_tokens:
|
|
67
|
+
break # this unit would overflow — leave it for the next chunk
|
|
68
|
+
tokens += unit_tokens
|
|
69
|
+
end += 1
|
|
70
|
+
chunks.append(_make_chunk(units[start:end], len(chunks), document))
|
|
71
|
+
if end >= count:
|
|
72
|
+
break
|
|
73
|
+
# Overlap stays within a section: a section boundary starts the next chunk fresh.
|
|
74
|
+
if _crosses_section(units, start, end):
|
|
75
|
+
start = end
|
|
76
|
+
else:
|
|
77
|
+
start = _next_start(units, start, end, options)
|
|
78
|
+
return chunks
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _crosses_section(units: list[_Unit], start: int, end: int) -> bool:
|
|
82
|
+
"""Whether ``units[end]`` opens a new section relative to the chunk started at ``start``.
|
|
83
|
+
|
|
84
|
+
True at an explicit heading unit *and* when the section label merely changes (an
|
|
85
|
+
empty-text heading resets the section without emitting a unit of its own), so a chunk
|
|
86
|
+
can never straddle a section boundary regardless of how it was introduced.
|
|
87
|
+
"""
|
|
88
|
+
return units[end].is_heading or units[end].section != units[start].section
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _iter_units(blocks: list[Block]) -> Iterator[_Unit]:
|
|
92
|
+
"""Flatten blocks into atomic units, tracking the section each unit belongs to."""
|
|
93
|
+
section = ""
|
|
94
|
+
for block in blocks:
|
|
95
|
+
if isinstance(block, Heading):
|
|
96
|
+
section = block.text
|
|
97
|
+
if block.text:
|
|
98
|
+
yield _Unit(text=block.text, section=section, is_heading=True)
|
|
99
|
+
elif isinstance(block, Table):
|
|
100
|
+
for row in block.rows:
|
|
101
|
+
if any(cell.strip() for cell in row): # skip a row whose every cell is blank
|
|
102
|
+
yield _Unit(text="\t".join(row), section=section, is_heading=False)
|
|
103
|
+
elif block.text: # Paragraph
|
|
104
|
+
yield _Unit(text=block.text, section=section, is_heading=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _make_chunk(group: list[_Unit], index: int, document: NormalizedDoc) -> Chunk:
|
|
108
|
+
metadata: dict[str, object] = {
|
|
109
|
+
"section": group[0].section, # every unit in the group shares one section
|
|
110
|
+
"page": document.page,
|
|
111
|
+
"sheet": document.sheet,
|
|
112
|
+
}
|
|
113
|
+
return Chunk(text="\n".join(unit.text for unit in group), metadata=metadata, index=index)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _next_start(units: list[_Unit], start: int, end: int, options: ChunkOptions) -> int:
|
|
117
|
+
"""Where the next chunk begins: back up from ``end`` to repeat ~``overlap_tokens``.
|
|
118
|
+
|
|
119
|
+
Never returns ``<= start``, so the scan always makes progress (an overlap larger than
|
|
120
|
+
the chunk simply repeats everything but the first unit). ``units[start:end]`` share one
|
|
121
|
+
section, so this overlap never crosses a heading boundary.
|
|
122
|
+
"""
|
|
123
|
+
cursor = end
|
|
124
|
+
tokens = 0
|
|
125
|
+
while cursor > start + 1 and tokens < options.overlap_tokens:
|
|
126
|
+
cursor -= 1
|
|
127
|
+
tokens += estimate_tokens(units[cursor].text)
|
|
128
|
+
return cursor
|