multixtract 0.1.1__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.
- multixtract/__init__.py +52 -0
- multixtract/chunking.py +297 -0
- multixtract/cli.py +75 -0
- multixtract/extraction.py +44 -0
- multixtract/extractors/__init__.py +84 -0
- multixtract/extractors/_image_utils.py +157 -0
- multixtract/extractors/docx.py +375 -0
- multixtract/extractors/eml.py +187 -0
- multixtract/extractors/epub.py +142 -0
- multixtract/extractors/excel.py +321 -0
- multixtract/extractors/html.py +127 -0
- multixtract/extractors/image.py +149 -0
- multixtract/extractors/legacy.py +148 -0
- multixtract/extractors/markdown.py +104 -0
- multixtract/extractors/pdf.py +347 -0
- multixtract/extractors/pptx.py +303 -0
- multixtract/extractors/registry.py +72 -0
- multixtract/extractors/rtf.py +54 -0
- multixtract/extractors/text.py +50 -0
- multixtract/filters.py +181 -0
- multixtract/interfaces.py +151 -0
- multixtract/pipeline.py +250 -0
- multixtract/providers/__init__.py +38 -0
- multixtract/providers/_utils.py +32 -0
- multixtract/providers/azure.py +91 -0
- multixtract/providers/llama.py +151 -0
- multixtract/providers/openai.py +125 -0
- multixtract/providers/qwen2vl.py +158 -0
- multixtract/providers/smolvlm.py +152 -0
- multixtract/providers/storage.py +97 -0
- multixtract/py.typed +1 -0
- multixtract/vision.py +120 -0
- multixtract-0.1.1.dist-info/METADATA +419 -0
- multixtract-0.1.1.dist-info/RECORD +37 -0
- multixtract-0.1.1.dist-info/WHEEL +4 -0
- multixtract-0.1.1.dist-info/entry_points.txt +2 -0
- multixtract-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Extractor registry — maps file extensions to DocumentExtractor factories.
|
|
2
|
+
|
|
3
|
+
The registry lets the pipeline pick the right extractor automatically based on
|
|
4
|
+
file type, and lets users register their own extractors for new formats::
|
|
5
|
+
|
|
6
|
+
from multixtract.extractors import register_extractor
|
|
7
|
+
from multixtract.interfaces import DocumentExtractor
|
|
8
|
+
|
|
9
|
+
class MarkdownExtractor:
|
|
10
|
+
extensions = (".md",)
|
|
11
|
+
def extract(self, path): ...
|
|
12
|
+
|
|
13
|
+
register_extractor(MarkdownExtractor())
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import Dict, Iterable
|
|
19
|
+
|
|
20
|
+
from ..interfaces import DocumentExtractor
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExtractorRegistry:
|
|
24
|
+
"""Dispatches a file path to the DocumentExtractor for its extension."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._by_ext: Dict[str, DocumentExtractor] = {}
|
|
28
|
+
|
|
29
|
+
def register(self, extractor: DocumentExtractor, extensions: Iterable[str] | None = None) -> DocumentExtractor:
|
|
30
|
+
"""Register *extractor* for the given *extensions* (defaults to the
|
|
31
|
+
extractor's own ``extensions`` attribute). Later registrations for the
|
|
32
|
+
same extension win, so users can override built-ins."""
|
|
33
|
+
exts = extensions if extensions is not None else getattr(extractor, "extensions", ())
|
|
34
|
+
for ext in exts:
|
|
35
|
+
self._by_ext[_norm(ext)] = extractor
|
|
36
|
+
return extractor
|
|
37
|
+
|
|
38
|
+
def get(self, path: str) -> DocumentExtractor:
|
|
39
|
+
"""Return the extractor for *path*'s extension, or raise."""
|
|
40
|
+
ext = _norm(os.path.splitext(path)[1])
|
|
41
|
+
extractor = self._by_ext.get(ext)
|
|
42
|
+
if extractor is None:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"No extractor registered for '{ext or path}'. "
|
|
45
|
+
f"Supported: {sorted(self._by_ext)}. "
|
|
46
|
+
f"Register one with register_extractor()."
|
|
47
|
+
)
|
|
48
|
+
return extractor
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def supported_extensions(self):
|
|
52
|
+
return sorted(self._by_ext)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _norm(ext: str) -> str:
|
|
56
|
+
ext = ext.lower().strip()
|
|
57
|
+
return ext if ext.startswith(".") or ext == "" else f".{ext}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Process-wide default registry. Built-in extractors register themselves on
|
|
61
|
+
# import of multixtract.extractors.
|
|
62
|
+
default_registry = ExtractorRegistry()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def register_extractor(extractor: DocumentExtractor, extensions: Iterable[str] | None = None) -> DocumentExtractor:
|
|
66
|
+
"""Register *extractor* on the process-wide default registry."""
|
|
67
|
+
return default_registry.register(extractor, extensions)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_extractor(path: str) -> DocumentExtractor:
|
|
71
|
+
"""Look up the default-registry extractor for *path*."""
|
|
72
|
+
return default_registry.get(path)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""RTF extractor (.rtf).
|
|
2
|
+
|
|
3
|
+
Requires striprtf (optional extra [rtf]).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("multixtract")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RtfExtractor:
|
|
15
|
+
"""DocumentExtractor for RTF files."""
|
|
16
|
+
|
|
17
|
+
extensions: Tuple[str, ...] = (".rtf",)
|
|
18
|
+
|
|
19
|
+
def extract(
|
|
20
|
+
self,
|
|
21
|
+
path: str,
|
|
22
|
+
image_filter: Optional[Any] = None,
|
|
23
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
24
|
+
base_name = os.path.splitext(os.path.basename(path))[0]
|
|
25
|
+
empty = {"_base_name": base_name, "metadata": {}, "pgs": []}
|
|
26
|
+
try:
|
|
27
|
+
from striprtf.striprtf import rtf_to_text
|
|
28
|
+
except ImportError as exc:
|
|
29
|
+
raise ImportError(
|
|
30
|
+
"RTF support requires striprtf: pip install 'multixtract[rtf]'"
|
|
31
|
+
) from exc
|
|
32
|
+
try:
|
|
33
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
34
|
+
raw = fh.read()
|
|
35
|
+
|
|
36
|
+
txt = rtf_to_text(raw).strip()
|
|
37
|
+
|
|
38
|
+
document = {
|
|
39
|
+
"_base_name": base_name,
|
|
40
|
+
"metadata": {
|
|
41
|
+
"page_count": 1,
|
|
42
|
+
"format": "rtf",
|
|
43
|
+
"char_count": len(txt),
|
|
44
|
+
},
|
|
45
|
+
"pgs": [
|
|
46
|
+
{"pg_num": 1, "txt": txt, "tables": [], "imgs": []}
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
return document, []
|
|
50
|
+
except ImportError:
|
|
51
|
+
raise
|
|
52
|
+
except Exception:
|
|
53
|
+
log.warning("RtfExtractor failed for %s", path, exc_info=True)
|
|
54
|
+
return empty, []
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Plain-text extractor (.txt / .log / .conf / .ini / .md).
|
|
2
|
+
|
|
3
|
+
The entire file becomes a single page. A dedicated MarkdownExtractor
|
|
4
|
+
registered later will override the .md extension with richer handling.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("multixtract")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TextExtractor:
|
|
16
|
+
"""DocumentExtractor for plain-text files."""
|
|
17
|
+
|
|
18
|
+
extensions: Tuple[str, ...] = (".txt", ".log", ".conf", ".ini", ".md")
|
|
19
|
+
|
|
20
|
+
def extract(
|
|
21
|
+
self,
|
|
22
|
+
path: str,
|
|
23
|
+
image_filter: Optional[Any] = None,
|
|
24
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
25
|
+
base_name = os.path.splitext(os.path.basename(path))[0]
|
|
26
|
+
fmt = os.path.splitext(path)[1].lstrip(".").lower() or "txt"
|
|
27
|
+
empty = {"_base_name": base_name, "metadata": {}, "pgs": []}
|
|
28
|
+
try:
|
|
29
|
+
try:
|
|
30
|
+
with open(path, encoding="utf-8") as fh:
|
|
31
|
+
text = fh.read()
|
|
32
|
+
except UnicodeDecodeError:
|
|
33
|
+
with open(path, encoding="latin-1", errors="replace") as fh:
|
|
34
|
+
text = fh.read()
|
|
35
|
+
|
|
36
|
+
document = {
|
|
37
|
+
"_base_name": base_name,
|
|
38
|
+
"metadata": {
|
|
39
|
+
"page_count": 1,
|
|
40
|
+
"format": fmt,
|
|
41
|
+
"char_count": len(text),
|
|
42
|
+
},
|
|
43
|
+
"pgs": [
|
|
44
|
+
{"pg_num": 1, "txt": text, "tables": [], "imgs": []}
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
return document, []
|
|
48
|
+
except Exception:
|
|
49
|
+
log.warning("TextExtractor failed for %s", path, exc_info=True)
|
|
50
|
+
return empty, []
|
multixtract/filters.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Image filtering pipeline (vendor-neutral).
|
|
2
|
+
|
|
3
|
+
Decides which extracted images are worth analyzing. Depends only on Pillow
|
|
4
|
+
and ImageHash — no cloud SDKs.
|
|
5
|
+
|
|
6
|
+
Filter order (correctness + performance):
|
|
7
|
+
1. Absolute minimum — max(w,h) < 20 -> instant reject (no PIL decode)
|
|
8
|
+
2. PIL decode — single decode shared by later stages
|
|
9
|
+
3. pHash compute — shared by logo detection
|
|
10
|
+
4. Reference-logo — highest priority so small logos are tagged, not kept
|
|
11
|
+
5. Dimension filter — two-threshold (max & min side)
|
|
12
|
+
6. Content quality — solid_color, tiny_icon
|
|
13
|
+
|
|
14
|
+
Cross-page dedup is handled by the extractor via xref tracking, not here.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import io
|
|
19
|
+
import os
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
import imagehash
|
|
24
|
+
from PIL import Image
|
|
25
|
+
|
|
26
|
+
_ABSOLUTE_MIN_DIM = 20
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ImageFilterPipeline:
|
|
30
|
+
"""Stateful image filtering and preparation.
|
|
31
|
+
|
|
32
|
+
One instance per process; call :meth:`reset` at the start of each document
|
|
33
|
+
to clear per-document filter statistics.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
SOLID_RANGE_MAX = 35
|
|
37
|
+
ICON_MAX_DIM = 200
|
|
38
|
+
ICON_MAX_COLORS = 8
|
|
39
|
+
SAMPLE_SIZE = 64
|
|
40
|
+
COLOR_SAMPLE_MAX = 256
|
|
41
|
+
LOGO_PHASH_THRESHOLD = 60
|
|
42
|
+
LOGO_ASPECT_RANGE = (0.2, 5.0)
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
min_image_size: int = 100,
|
|
47
|
+
min_image_size_minor: int = 75,
|
|
48
|
+
reference_img_dir: str = "",
|
|
49
|
+
) -> None:
|
|
50
|
+
self.min_image_size = min_image_size
|
|
51
|
+
self.min_image_size_minor = min_image_size_minor
|
|
52
|
+
self._reference_hashes: List[Tuple[imagehash.ImageHash, str]] = []
|
|
53
|
+
if reference_img_dir:
|
|
54
|
+
self._load_reference_images(reference_img_dir)
|
|
55
|
+
self.reset()
|
|
56
|
+
|
|
57
|
+
# ---- reference logos -------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _load_reference_images(self, ref_dir: str) -> None:
|
|
60
|
+
if not os.path.isdir(ref_dir):
|
|
61
|
+
return
|
|
62
|
+
exts = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"}
|
|
63
|
+
for fname in sorted(os.listdir(ref_dir)):
|
|
64
|
+
if os.path.splitext(fname)[1].lower() not in exts:
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
img = Image.open(os.path.join(ref_dir, fname)).convert("RGB")
|
|
68
|
+
try:
|
|
69
|
+
self._reference_hashes.append((imagehash.phash(img, hash_size=16), fname))
|
|
70
|
+
finally:
|
|
71
|
+
img.close()
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def _is_reference_logo(self, phash, width: int, height: int) -> Tuple[bool, str]:
|
|
76
|
+
if not self._reference_hashes:
|
|
77
|
+
return False, ""
|
|
78
|
+
aspect = width / max(height, 1)
|
|
79
|
+
aspect_min, aspect_max = self.LOGO_ASPECT_RANGE
|
|
80
|
+
if not (aspect_min <= aspect <= aspect_max):
|
|
81
|
+
return False, ""
|
|
82
|
+
best_dist, best_ref = min(
|
|
83
|
+
((phash - rh, rn) for rh, rn in self._reference_hashes),
|
|
84
|
+
key=lambda x: x[0],
|
|
85
|
+
)
|
|
86
|
+
return (best_dist <= self.LOGO_PHASH_THRESHOLD, best_ref)
|
|
87
|
+
|
|
88
|
+
# ---- per-document state ---------------------------------------------
|
|
89
|
+
|
|
90
|
+
def reset(self) -> None:
|
|
91
|
+
"""Reset per-document filter statistics."""
|
|
92
|
+
self._filter_stats: Dict[str, int] = defaultdict(int)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def filter_stats(self) -> Dict[str, int]:
|
|
96
|
+
return dict(self._filter_stats)
|
|
97
|
+
|
|
98
|
+
def note_duplicate(self) -> None:
|
|
99
|
+
"""Record a cross-page duplicate (called by the extractor)."""
|
|
100
|
+
self._filter_stats["duplicate"] += 1
|
|
101
|
+
|
|
102
|
+
# ---- content quality -------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def _is_low_value(self, small, small_nearest) -> Tuple[bool, str]:
|
|
105
|
+
if max(channel_max - channel_min for channel_min, channel_max in small.getextrema()) < self.SOLID_RANGE_MAX:
|
|
106
|
+
return True, "solid_color"
|
|
107
|
+
if small_nearest is not None:
|
|
108
|
+
try:
|
|
109
|
+
ic = small_nearest.getcolors(maxcolors=self.COLOR_SAMPLE_MAX)
|
|
110
|
+
if ic and len(ic) <= self.ICON_MAX_COLORS:
|
|
111
|
+
return True, "tiny_icon"
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
return False, ""
|
|
115
|
+
|
|
116
|
+
# ---- main entry point ------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def prepare_image(
|
|
119
|
+
self,
|
|
120
|
+
image_bytes: bytes,
|
|
121
|
+
ext: str,
|
|
122
|
+
width: int,
|
|
123
|
+
height: int,
|
|
124
|
+
image_id: str,
|
|
125
|
+
page_number: int,
|
|
126
|
+
img_idx: int,
|
|
127
|
+
) -> Optional[Dict[str, Any]]:
|
|
128
|
+
"""Apply all filters to one image. Returns a metadata dict for images
|
|
129
|
+
that pass, or ``None`` for images that are filtered out. The returned
|
|
130
|
+
dict carries ``image_bytes`` so the caller can run vision on it.
|
|
131
|
+
"""
|
|
132
|
+
if max(width, height) < _ABSOLUTE_MIN_DIM:
|
|
133
|
+
self._filter_stats["dimension"] += 1
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
|
138
|
+
except Exception:
|
|
139
|
+
self._filter_stats["decode_error"] += 1
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
phash = imagehash.phash(img, hash_size=16)
|
|
144
|
+
|
|
145
|
+
is_logo, _ = self._is_reference_logo(phash, width, height)
|
|
146
|
+
if is_logo:
|
|
147
|
+
self._filter_stats["ref_logo"] += 1
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
if max(width, height) < self.min_image_size or min(width, height) < self.min_image_size_minor:
|
|
151
|
+
self._filter_stats["dimension"] += 1
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
sample_size = self.SAMPLE_SIZE
|
|
155
|
+
small = img.resize((sample_size, sample_size), Image.Resampling.NEAREST)
|
|
156
|
+
# Reuse `small` for the icon colour check — same dimensions, no second decode.
|
|
157
|
+
small_nearest = small if max(width, height) < self.ICON_MAX_DIM else None
|
|
158
|
+
try:
|
|
159
|
+
skip, reason = self._is_low_value(small, small_nearest)
|
|
160
|
+
except Exception:
|
|
161
|
+
skip, reason = False, ""
|
|
162
|
+
finally:
|
|
163
|
+
small.close()
|
|
164
|
+
# small_nearest is either None or the same object as small; don't double-close.
|
|
165
|
+
if skip:
|
|
166
|
+
self._filter_stats[reason] += 1
|
|
167
|
+
return None
|
|
168
|
+
finally:
|
|
169
|
+
img.close()
|
|
170
|
+
|
|
171
|
+
self._filter_stats["kept"] += 1
|
|
172
|
+
return {
|
|
173
|
+
"image_id": image_id,
|
|
174
|
+
"page_number": page_number,
|
|
175
|
+
"img_idx": img_idx,
|
|
176
|
+
"image_bytes": image_bytes,
|
|
177
|
+
"ext": ext,
|
|
178
|
+
"width": width,
|
|
179
|
+
"height": height,
|
|
180
|
+
"img_path": f"pg{page_number}_img{img_idx}.{ext}",
|
|
181
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Vendor-neutral provider interfaces.
|
|
2
|
+
|
|
3
|
+
The core pipeline depends ONLY on these contracts — never on a concrete
|
|
4
|
+
vendor SDK. Swap OpenAI for a local model, Azure Blob for S3 or local disk,
|
|
5
|
+
by providing a class that satisfies the relevant Protocol.
|
|
6
|
+
|
|
7
|
+
Protocols use structural typing: any object with the right methods works,
|
|
8
|
+
no explicit subclassing required.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class VisionResult:
|
|
18
|
+
"""Structured output of a vision model describing a single image."""
|
|
19
|
+
|
|
20
|
+
caption: str = ""
|
|
21
|
+
ocr_text: str = ""
|
|
22
|
+
description: str = ""
|
|
23
|
+
|
|
24
|
+
def best_text(self) -> str:
|
|
25
|
+
"""Text most suitable for embedding (description, else caption)."""
|
|
26
|
+
return self.description or self.caption or ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class VisionModel(Protocol):
|
|
31
|
+
"""Turns image bytes into a caption + OCR text + description.
|
|
32
|
+
|
|
33
|
+
Implement this to plug in any vision-capable model (GPT-4o, Claude,
|
|
34
|
+
Gemini, a local Llama 3.2 Vision, etc.).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def analyze(
|
|
38
|
+
self,
|
|
39
|
+
image_bytes: bytes,
|
|
40
|
+
ext: str = "png",
|
|
41
|
+
width: int = 0,
|
|
42
|
+
height: int = 0,
|
|
43
|
+
) -> VisionResult:
|
|
44
|
+
"""Return a :class:`VisionResult` for one image. Must not raise on
|
|
45
|
+
model errors — return an empty ``VisionResult`` instead."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class Embedder(Protocol):
|
|
51
|
+
"""Turns text into fixed-length numeric vectors.
|
|
52
|
+
|
|
53
|
+
Implement this to plug in any embedding model (OpenAI, Cohere,
|
|
54
|
+
sentence-transformers, etc.).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
#: Dimensionality of the vectors this embedder produces.
|
|
58
|
+
dim: int
|
|
59
|
+
|
|
60
|
+
def embed(self, texts: List[str]) -> List[Optional[List[float]]]:
|
|
61
|
+
"""Embed a batch of texts. Returns one vector per input (same order);
|
|
62
|
+
``None`` for empty or failed items. Implementations are encouraged to
|
|
63
|
+
batch internally to minimise API calls."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@runtime_checkable
|
|
68
|
+
class BlobStore(Protocol):
|
|
69
|
+
"""Persists bytes and JSON to some backing store.
|
|
70
|
+
|
|
71
|
+
Implement this to plug in any storage target (local disk, Azure Blob,
|
|
72
|
+
S3, GCS, etc.). ``path`` is always a relative, forward-slash path.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def put_bytes(self, path: str, data: bytes, content_type: str = "") -> str:
|
|
76
|
+
"""Store raw bytes at *path*. Returns a locator (URL or absolute path)."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def put_json(self, path: str, obj: object, compact: bool = False) -> str:
|
|
80
|
+
"""Serialize *obj* to JSON and store at *path*. When *compact* is True,
|
|
81
|
+
use separator-only JSON (~30-40% smaller). Returns a locator."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
def exists(self, path: str) -> bool:
|
|
85
|
+
"""Return True if an object already exists at *path* (resume support)."""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@runtime_checkable
|
|
90
|
+
class DocumentExtractor(Protocol):
|
|
91
|
+
"""Reads a document file into the normalized document structure.
|
|
92
|
+
|
|
93
|
+
Implement this to support a new file format (PDF, Word, PowerPoint, Excel,
|
|
94
|
+
HTML, ...). The downstream pipeline (filtering, vision, chunking, embedding,
|
|
95
|
+
storage) is format-agnostic and consumes the structure returned here.
|
|
96
|
+
|
|
97
|
+
The returned document is a dict::
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
"metadata": {...},
|
|
101
|
+
"_base_name": "<file stem>",
|
|
102
|
+
"pgs": [ # a "page" = page / slide / sheet / section
|
|
103
|
+
{"pg_num": 1, "kind": "page", "txt": "...",
|
|
104
|
+
"tables": [[[...]]], "imgs": []},
|
|
105
|
+
...
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
and ``prepared_images`` is the list of images that passed filtering, each a
|
|
110
|
+
dict carrying ``image_bytes`` for downstream vision analysis.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
#: Lower-case file extensions this extractor handles, e.g. ``(".pdf",)``.
|
|
114
|
+
extensions: Tuple[str, ...]
|
|
115
|
+
|
|
116
|
+
def extract(
|
|
117
|
+
self,
|
|
118
|
+
path: str,
|
|
119
|
+
image_filter: Optional[Any] = None,
|
|
120
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
121
|
+
"""Return ``(document, prepared_images)`` for the file at *path*.
|
|
122
|
+
|
|
123
|
+
*image_filter* is an optional :class:`ImageFilterPipeline`; when omitted
|
|
124
|
+
the extractor should create a default one. Text-only extractors may
|
|
125
|
+
ignore it.
|
|
126
|
+
"""
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class PipelineConfig:
|
|
132
|
+
"""Tunable knobs shared across the pipeline."""
|
|
133
|
+
|
|
134
|
+
# Image filtering
|
|
135
|
+
min_image_size: int = 100
|
|
136
|
+
min_image_size_minor: int = 75
|
|
137
|
+
reference_img_dir: str = ""
|
|
138
|
+
|
|
139
|
+
# Vision / embedding
|
|
140
|
+
vision_workers: int = 6
|
|
141
|
+
embed_text_limit: int = 8000
|
|
142
|
+
|
|
143
|
+
# Chunking
|
|
144
|
+
chunk_target_tokens: int = 500
|
|
145
|
+
chunk_overlap_tokens: int = 50
|
|
146
|
+
|
|
147
|
+
# Storage layout (relative sub-folders under the store root)
|
|
148
|
+
images_subdir: str = "extracted_images"
|
|
149
|
+
doc_json_subdir: str = "jsons"
|
|
150
|
+
image_json_subdir: str = "image_jsons"
|
|
151
|
+
chunks_subdir: str = "chunks"
|