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
multixtract/pipeline.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Pipeline orchestrator.
|
|
2
|
+
|
|
3
|
+
Wires the vendor-neutral core (extraction -> filtering -> chunking) together
|
|
4
|
+
with *injected* providers (vision, embedder, store). The orchestrator never
|
|
5
|
+
imports a vendor SDK — it only talks to the interfaces.
|
|
6
|
+
|
|
7
|
+
Providers are optional:
|
|
8
|
+
* no ``vision`` -> images are kept but not described
|
|
9
|
+
* no ``embedder`` -> chunks are produced without embeddings
|
|
10
|
+
* no ``store`` -> nothing is persisted (results returned in memory)
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from .chunking import chunk_document
|
|
22
|
+
from .extraction import extract_document
|
|
23
|
+
from .filters import ImageFilterPipeline
|
|
24
|
+
from .interfaces import BlobStore, Embedder, PipelineConfig, VisionModel
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("multixtract")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ExtractionResult:
|
|
31
|
+
"""Everything the pipeline produced for one document."""
|
|
32
|
+
|
|
33
|
+
base_name: str
|
|
34
|
+
document: Dict[str, Any]
|
|
35
|
+
chunks: List[Dict[str, Any]] = field(default_factory=list)
|
|
36
|
+
image_index: List[Dict[str, Any]] = field(default_factory=list)
|
|
37
|
+
filter_stats: Dict[str, int] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Pipeline:
|
|
41
|
+
"""End-to-end document extraction pipeline.
|
|
42
|
+
|
|
43
|
+
Example::
|
|
44
|
+
|
|
45
|
+
pipeline = Pipeline(vision=my_vision, embedder=my_embedder, store=my_store)
|
|
46
|
+
result = pipeline.process("report.pdf")
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
vision: Optional[VisionModel] = None,
|
|
52
|
+
embedder: Optional[Embedder] = None,
|
|
53
|
+
store: Optional[BlobStore] = None,
|
|
54
|
+
config: Optional[PipelineConfig] = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.vision = vision
|
|
57
|
+
self.embedder = embedder
|
|
58
|
+
self.store = store
|
|
59
|
+
self.config = config or PipelineConfig()
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def process(self, doc_path: str, skip_if_exists: bool = True) -> ExtractionResult:
|
|
64
|
+
"""Run the full pipeline on a single document.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: If no extractor is registered for the file's extension.
|
|
68
|
+
ImportError: If the required optional extra for this format is not installed.
|
|
69
|
+
"""
|
|
70
|
+
base_name = os.path.splitext(os.path.basename(doc_path))[0]
|
|
71
|
+
config = self.config
|
|
72
|
+
|
|
73
|
+
# Resume support: skip documents already stored.
|
|
74
|
+
if skip_if_exists and self.store is not None:
|
|
75
|
+
doc_key = f"{config.doc_json_subdir}/{base_name}.json"
|
|
76
|
+
if self.store.exists(doc_key):
|
|
77
|
+
log.info("Skipping %s (output exists)", base_name)
|
|
78
|
+
return ExtractionResult(base_name=base_name, document={})
|
|
79
|
+
|
|
80
|
+
# Phase 1 — extract + filter
|
|
81
|
+
_filter = ImageFilterPipeline(
|
|
82
|
+
min_image_size=config.min_image_size,
|
|
83
|
+
min_image_size_minor=config.min_image_size_minor,
|
|
84
|
+
reference_img_dir=config.reference_img_dir,
|
|
85
|
+
)
|
|
86
|
+
document, prepared = extract_document(doc_path, image_filter=_filter)
|
|
87
|
+
|
|
88
|
+
# Persist raw image bytes before vision frees them.
|
|
89
|
+
if self.store is not None:
|
|
90
|
+
self._persist_images(base_name, prepared)
|
|
91
|
+
|
|
92
|
+
# Phase 2a — vision (parallel)
|
|
93
|
+
vision_by_id = self._run_vision(prepared)
|
|
94
|
+
|
|
95
|
+
# Phase 2b — embed image descriptions
|
|
96
|
+
image_embeds = self._embed_images(prepared, vision_by_id)
|
|
97
|
+
|
|
98
|
+
# Assemble image metadata onto pages + a flat image index
|
|
99
|
+
image_index = self._assemble_images(document, prepared, vision_by_id, image_embeds)
|
|
100
|
+
|
|
101
|
+
# Phase 3 — chunk + embed chunks (wired to config)
|
|
102
|
+
chunks = chunk_document(
|
|
103
|
+
document,
|
|
104
|
+
base_name,
|
|
105
|
+
image_embeddings=image_embeds,
|
|
106
|
+
target_tokens=config.chunk_target_tokens,
|
|
107
|
+
overlap_tokens=config.chunk_overlap_tokens,
|
|
108
|
+
)
|
|
109
|
+
self._embed_chunks(chunks)
|
|
110
|
+
|
|
111
|
+
stamp = {
|
|
112
|
+
"doc_id": base_name,
|
|
113
|
+
"file_name": os.path.basename(doc_path),
|
|
114
|
+
"file_path": doc_path,
|
|
115
|
+
"file_type": os.path.splitext(doc_path)[1].lstrip(".").lower(),
|
|
116
|
+
"total_pgs": len(document.get("pgs", [])),
|
|
117
|
+
"last_updated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
118
|
+
}
|
|
119
|
+
for chunk in chunks:
|
|
120
|
+
chunk.update(stamp)
|
|
121
|
+
|
|
122
|
+
result = ExtractionResult(
|
|
123
|
+
base_name=base_name,
|
|
124
|
+
document=document,
|
|
125
|
+
chunks=chunks,
|
|
126
|
+
image_index=image_index,
|
|
127
|
+
filter_stats=_filter.filter_stats,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if self.store is not None:
|
|
131
|
+
self._persist(result)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def _run_vision(self, prepared: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
137
|
+
if self.vision is None or not prepared:
|
|
138
|
+
return {}
|
|
139
|
+
results: Dict[str, Any] = {}
|
|
140
|
+
workers = min(self.config.vision_workers, len(prepared))
|
|
141
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
142
|
+
futures = {
|
|
143
|
+
pool.submit(
|
|
144
|
+
self.vision.analyze,
|
|
145
|
+
img["image_bytes"], img["ext"], img["width"], img["height"],
|
|
146
|
+
): img["image_id"]
|
|
147
|
+
for img in prepared
|
|
148
|
+
}
|
|
149
|
+
for fut in as_completed(futures):
|
|
150
|
+
image_id = futures[fut]
|
|
151
|
+
try:
|
|
152
|
+
results[image_id] = fut.result()
|
|
153
|
+
except Exception as exc: # provider should not raise, but be safe
|
|
154
|
+
log.warning("vision failed for %s: %s", image_id, exc)
|
|
155
|
+
# Free image bytes once vision is done.
|
|
156
|
+
for img in prepared:
|
|
157
|
+
img.pop("image_bytes", None)
|
|
158
|
+
return results
|
|
159
|
+
|
|
160
|
+
def _embed_images(self, prepared, vision_by_id) -> Dict[str, List[float]]:
|
|
161
|
+
if self.embedder is None:
|
|
162
|
+
return {}
|
|
163
|
+
pairs = [
|
|
164
|
+
(img["image_id"], vision_by_id[img["image_id"]].best_text())
|
|
165
|
+
for img in prepared
|
|
166
|
+
if vision_by_id.get(img["image_id"]) and vision_by_id[img["image_id"]].best_text()
|
|
167
|
+
]
|
|
168
|
+
if not pairs:
|
|
169
|
+
return {}
|
|
170
|
+
ids, texts = zip(*pairs)
|
|
171
|
+
all_vectors = self.embedder.embed(list(texts))
|
|
172
|
+
return {i: v for i, v in zip(ids, all_vectors) if v is not None}
|
|
173
|
+
|
|
174
|
+
def _assemble_images(self, document, prepared, vision_by_id, image_embeds) -> List[Dict[str, Any]]:
|
|
175
|
+
image_index: List[Dict[str, Any]] = []
|
|
176
|
+
pages_by_num = {page["pg_num"]: page for page in document.get("pgs", [])}
|
|
177
|
+
for img in prepared:
|
|
178
|
+
vision_result = vision_by_id.get(img["image_id"])
|
|
179
|
+
page_meta = {
|
|
180
|
+
"img_id": img["image_id"],
|
|
181
|
+
"img_idx": img["img_idx"],
|
|
182
|
+
"img_path": img["img_path"],
|
|
183
|
+
"ocr_text": vision_result.ocr_text if vision_result else "",
|
|
184
|
+
"caption": vision_result.caption if vision_result else "",
|
|
185
|
+
"description": vision_result.description if vision_result else "",
|
|
186
|
+
}
|
|
187
|
+
# Safe page lookup
|
|
188
|
+
page = pages_by_num.get(img["page_number"])
|
|
189
|
+
if page is not None:
|
|
190
|
+
page["imgs"].append(page_meta)
|
|
191
|
+
image_index.append({
|
|
192
|
+
**page_meta,
|
|
193
|
+
"pg_num": img["page_number"],
|
|
194
|
+
"width": img["width"],
|
|
195
|
+
"height": img["height"],
|
|
196
|
+
"format": img["ext"],
|
|
197
|
+
"embedding": image_embeds.get(img["image_id"]),
|
|
198
|
+
})
|
|
199
|
+
return image_index
|
|
200
|
+
|
|
201
|
+
def _persist_images(self, base_name: str, prepared: List[Dict[str, Any]]) -> None:
|
|
202
|
+
config = self.config
|
|
203
|
+
ext_to_mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
|
204
|
+
"gif": "image/gif", "bmp": "image/bmp",
|
|
205
|
+
"tiff": "image/tiff", "tif": "image/tiff",
|
|
206
|
+
"webp": "image/webp"}
|
|
207
|
+
for img in prepared:
|
|
208
|
+
blob_path = f"{config.images_subdir}/{base_name}/{img['img_path']}"
|
|
209
|
+
mime = ext_to_mime.get(img["ext"].lower(), "application/octet-stream")
|
|
210
|
+
locator = self.store.put_bytes(blob_path, img["image_bytes"], content_type=mime)
|
|
211
|
+
img["img_path"] = locator
|
|
212
|
+
|
|
213
|
+
def _embed_chunks(self, chunks: List[Dict[str, Any]]) -> None:
|
|
214
|
+
if self.embedder is None or not chunks:
|
|
215
|
+
return
|
|
216
|
+
pending = [(chunk_index, chunk) for chunk_index, chunk in enumerate(chunks) if chunk["embedding"] is None]
|
|
217
|
+
if not pending:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
texts = [chunk["content"][: self.config.embed_text_limit] for _, chunk in pending]
|
|
221
|
+
all_vectors = self.embedder.embed(texts)
|
|
222
|
+
if len(all_vectors) != len(pending):
|
|
223
|
+
log.warning("embedder returned %d vectors for %d chunks; some embeddings will be None",
|
|
224
|
+
len(all_vectors), len(pending))
|
|
225
|
+
for (chunk_index, _), vec in zip(pending, all_vectors):
|
|
226
|
+
chunks[chunk_index]["embedding"] = vec
|
|
227
|
+
|
|
228
|
+
def _persist(self, result: ExtractionResult) -> None:
|
|
229
|
+
config = self.config
|
|
230
|
+
base_name = result.base_name
|
|
231
|
+
# Write image and chunk blobs first; doc JSON is written last and acts
|
|
232
|
+
# as the completion marker checked by skip_if_exists. A partial write
|
|
233
|
+
# (e.g. network error mid-way) leaves the doc key absent so retries
|
|
234
|
+
# re-process the document instead of skipping it permanently.
|
|
235
|
+
self.store.put_json(
|
|
236
|
+
f"{config.image_json_subdir}/{base_name}_image.json",
|
|
237
|
+
{"imgs": result.image_index},
|
|
238
|
+
)
|
|
239
|
+
self.store.put_json(
|
|
240
|
+
f"{config.chunks_subdir}/{base_name}_chunks.json",
|
|
241
|
+
{
|
|
242
|
+
"_header": {
|
|
243
|
+
"file_name": base_name,
|
|
244
|
+
"total_pgs": len(result.document.get("pgs", [])),
|
|
245
|
+
},
|
|
246
|
+
"chunks": result.chunks,
|
|
247
|
+
},
|
|
248
|
+
compact=True,
|
|
249
|
+
)
|
|
250
|
+
self.store.put_json(f"{config.doc_json_subdir}/{base_name}.json", result.document)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Concrete provider implementations.
|
|
2
|
+
|
|
3
|
+
Importing a provider class does not pull in its SDK until you instantiate it.
|
|
4
|
+
Each submodule is imported lazily here so a broken or missing optional
|
|
5
|
+
dependency in one provider never prevents other providers from loading.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
def __getattr__(name: str):
|
|
9
|
+
_map = {
|
|
10
|
+
"OpenAIVisionModel": (".openai", "OpenAIVisionModel"),
|
|
11
|
+
"OpenAIEmbedder": (".openai", "OpenAIEmbedder"),
|
|
12
|
+
"AzureOpenAIVisionModel": (".azure", "AzureOpenAIVisionModel"),
|
|
13
|
+
"AzureOpenAIEmbedder": (".azure", "AzureOpenAIEmbedder"),
|
|
14
|
+
"AzureBlobStore": (".storage", "AzureBlobStore"),
|
|
15
|
+
"LocalDiskStore": (".storage", "LocalDiskStore"),
|
|
16
|
+
"Llama32VisionModel": (".llama", "Llama32VisionModel"),
|
|
17
|
+
"Qwen2VLVisionModel": (".qwen2vl", "Qwen2VLVisionModel"),
|
|
18
|
+
"SmolVLMVisionModel": (".smolvlm", "SmolVLMVisionModel"),
|
|
19
|
+
}
|
|
20
|
+
if name in _map:
|
|
21
|
+
module_rel, attr = _map[name]
|
|
22
|
+
import importlib
|
|
23
|
+
mod = importlib.import_module(module_rel, package=__name__)
|
|
24
|
+
return getattr(mod, attr)
|
|
25
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"OpenAIVisionModel",
|
|
30
|
+
"OpenAIEmbedder",
|
|
31
|
+
"AzureOpenAIVisionModel",
|
|
32
|
+
"AzureOpenAIEmbedder",
|
|
33
|
+
"LocalDiskStore",
|
|
34
|
+
"AzureBlobStore",
|
|
35
|
+
"Qwen2VLVisionModel",
|
|
36
|
+
"SmolVLMVisionModel",
|
|
37
|
+
"Llama32VisionModel",
|
|
38
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared helpers for provider implementations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _infer_device() -> str:
|
|
9
|
+
"""Return 'cuda' if a CUDA GPU is available, else 'cpu'."""
|
|
10
|
+
try:
|
|
11
|
+
import torch
|
|
12
|
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
|
13
|
+
except Exception:
|
|
14
|
+
return "cpu"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@contextlib.contextmanager
|
|
18
|
+
def _open_image_rgb(image_bytes: bytes):
|
|
19
|
+
"""Open raw image bytes and yield a converted RGB PIL Image.
|
|
20
|
+
|
|
21
|
+
Closes both the raw and the converted image on exit, even on error.
|
|
22
|
+
"""
|
|
23
|
+
from PIL import Image
|
|
24
|
+
raw = Image.open(io.BytesIO(image_bytes))
|
|
25
|
+
try:
|
|
26
|
+
image = raw.convert("RGB")
|
|
27
|
+
finally:
|
|
28
|
+
raw.close()
|
|
29
|
+
try:
|
|
30
|
+
yield image
|
|
31
|
+
finally:
|
|
32
|
+
image.close()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Azure OpenAI providers (optional extra: ``pip install multixtract[azure]``).
|
|
2
|
+
|
|
3
|
+
These reuse the OpenAI provider logic but construct an ``AzureOpenAI`` client.
|
|
4
|
+
Pass credentials explicitly — never hard-code secrets. Source them from
|
|
5
|
+
environment variables, a secrets manager (Azure Key Vault, AWS Secrets Manager,
|
|
6
|
+
HashiCorp Vault, etc.), or your platform's secret store.
|
|
7
|
+
|
|
8
|
+
Both classes support passwordless / managed-identity auth via
|
|
9
|
+
``azure_ad_token_provider`` — a callable that returns a fresh bearer token.
|
|
10
|
+
When provided, ``api_key`` should be omitted (or ``None``).
|
|
11
|
+
|
|
12
|
+
Example (managed identity)::
|
|
13
|
+
|
|
14
|
+
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
|
15
|
+
credential = DefaultAzureCredential()
|
|
16
|
+
token_provider = get_bearer_token_provider(
|
|
17
|
+
credential, "https://cognitiveservices.azure.com/.default"
|
|
18
|
+
)
|
|
19
|
+
vision = AzureOpenAIVisionModel(
|
|
20
|
+
endpoint="https://<resource>.openai.azure.com/",
|
|
21
|
+
azure_ad_token_provider=token_provider,
|
|
22
|
+
)
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Callable, Optional
|
|
27
|
+
|
|
28
|
+
from .openai import OpenAIEmbedder, OpenAIVisionModel
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _azure_client(
|
|
32
|
+
endpoint: str,
|
|
33
|
+
api_key: Optional[str],
|
|
34
|
+
api_version: str,
|
|
35
|
+
azure_ad_token_provider: Optional[Callable[[], str]] = None,
|
|
36
|
+
):
|
|
37
|
+
from openai import AzureOpenAI # keeps openai optional for core users
|
|
38
|
+
return AzureOpenAI(
|
|
39
|
+
azure_endpoint=endpoint,
|
|
40
|
+
api_key=api_key,
|
|
41
|
+
api_version=api_version,
|
|
42
|
+
azure_ad_token_provider=azure_ad_token_provider,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AzureOpenAIVisionModel(OpenAIVisionModel):
|
|
47
|
+
"""Vision provider backed by an Azure OpenAI deployment (e.g. gpt-4o).
|
|
48
|
+
|
|
49
|
+
Supports both API-key and passwordless (Azure AD / managed identity) auth.
|
|
50
|
+
Pass ``azure_ad_token_provider`` for passwordless; omit ``api_key`` in that case.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
endpoint: str,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
deployment: str = "gpt-4o",
|
|
58
|
+
api_version: str = "2024-10-21",
|
|
59
|
+
azure_ad_token_provider: Optional[Callable[[], str]] = None,
|
|
60
|
+
**kwargs,
|
|
61
|
+
) -> None:
|
|
62
|
+
super().__init__(
|
|
63
|
+
model=deployment,
|
|
64
|
+
client=_azure_client(endpoint, api_key, api_version, azure_ad_token_provider),
|
|
65
|
+
**kwargs,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AzureOpenAIEmbedder(OpenAIEmbedder):
|
|
70
|
+
"""Embedding provider backed by an Azure OpenAI deployment.
|
|
71
|
+
|
|
72
|
+
Supports both API-key and passwordless (Azure AD / managed identity) auth.
|
|
73
|
+
Pass ``azure_ad_token_provider`` for passwordless; omit ``api_key`` in that case.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
endpoint: str,
|
|
79
|
+
api_key: Optional[str] = None,
|
|
80
|
+
deployment: str = "text-embedding-3-large",
|
|
81
|
+
api_version: str = "2024-10-21",
|
|
82
|
+
dim: int = 1024,
|
|
83
|
+
azure_ad_token_provider: Optional[Callable[[], str]] = None,
|
|
84
|
+
**kwargs,
|
|
85
|
+
) -> None:
|
|
86
|
+
super().__init__(
|
|
87
|
+
model=deployment,
|
|
88
|
+
dim=dim,
|
|
89
|
+
client=_azure_client(endpoint, api_key, api_version, azure_ad_token_provider),
|
|
90
|
+
**kwargs,
|
|
91
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Llama 3.2 Vision provider (optional extra: ``pip install multixtract[llama]``).
|
|
2
|
+
|
|
3
|
+
Runs Llama 3.2 Vision **fully offline** via HuggingFace transformers —
|
|
4
|
+
no cloud, no API key. It produces the same GPT-4o-style
|
|
5
|
+
caption / OCR / description output as the cloud providers by reusing the shared
|
|
6
|
+
:data:`DEFAULT_SYSTEM_PROMPT` and :func:`parse_vision_response`, so it is a
|
|
7
|
+
drop-in ``VisionModel`` for the pipeline or the standalone recipes.
|
|
8
|
+
|
|
9
|
+
A CUDA GPU is strongly recommended — the 11B model requires ≥16 GB VRAM. The
|
|
10
|
+
first call downloads the model weights from the HuggingFace hub; set
|
|
11
|
+
``HF_HOME`` to a persistent path to cache them.
|
|
12
|
+
|
|
13
|
+
Example::
|
|
14
|
+
|
|
15
|
+
from multixtract import extract_document
|
|
16
|
+
from multixtract.providers import Llama32VisionModel
|
|
17
|
+
|
|
18
|
+
vision = Llama32VisionModel() # 11B default
|
|
19
|
+
vision = Llama32VisionModel("meta-llama/Llama-3.2-11B-Vision-Instruct")
|
|
20
|
+
_, images = extract_document("report.pdf")
|
|
21
|
+
for img in images:
|
|
22
|
+
r = vision.analyze(img["image_bytes"], img["ext"])
|
|
23
|
+
print(r.caption, "|", r.description)
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import threading
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from ..interfaces import VisionModel, VisionResult
|
|
32
|
+
from ..vision import DEFAULT_SYSTEM_PROMPT, parse_vision_response
|
|
33
|
+
from ._utils import _infer_device, _open_image_rgb
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger("multixtract")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Llama32VisionModel(VisionModel):
|
|
39
|
+
"""Offline ``VisionModel`` backed by Llama 3.2 Vision (transformers).
|
|
40
|
+
|
|
41
|
+
The heavy deps (``torch`` / ``transformers`` / ``accelerate``) are imported
|
|
42
|
+
lazily, so simply importing this module never pulls them in. The model is
|
|
43
|
+
loaded once, on construction, and reused across :meth:`analyze` calls.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
model_id: str = "meta-llama/Llama-3.2-11B-Vision-Instruct",
|
|
49
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
|
50
|
+
max_new_tokens: int = 512,
|
|
51
|
+
device: Optional[str] = None,
|
|
52
|
+
torch_dtype: str = "bfloat16",
|
|
53
|
+
load_in_4bit: bool = False,
|
|
54
|
+
model=None,
|
|
55
|
+
processor=None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Load a Llama 3.2 Vision model.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
model_id: HuggingFace model id.
|
|
61
|
+
``meta-llama/Llama-3.2-11B-Vision-Instruct`` (default, best accuracy),
|
|
62
|
+
``meta-llama/Llama-3.2-90B-Vision-Instruct`` (highest accuracy, needs 80+ GB VRAM).
|
|
63
|
+
system_prompt: Instruction sent with every image. Defaults to the
|
|
64
|
+
shared, parseable CAPTION/OCR_TEXT/DESCRIPTION prompt.
|
|
65
|
+
max_new_tokens: Generation budget per image.
|
|
66
|
+
device: ``"cuda"`` or ``"cpu"``. Auto-detected when omitted.
|
|
67
|
+
torch_dtype: dtype name used on GPU (``"bfloat16"`` recommended for Llama 3.2).
|
|
68
|
+
load_in_4bit: 4-bit quantised load (needs ``bitsandbytes``); halves
|
|
69
|
+
VRAM for the 11B model.
|
|
70
|
+
model / processor: Pre-built instances to reuse (skips loading).
|
|
71
|
+
"""
|
|
72
|
+
self.model_id = model_id
|
|
73
|
+
self.system_prompt = system_prompt
|
|
74
|
+
self.max_new_tokens = max_new_tokens
|
|
75
|
+
|
|
76
|
+
self._lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
if model is not None and processor is not None:
|
|
79
|
+
self._model, self._processor = model, processor
|
|
80
|
+
self.device = device or _infer_device()
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
import torch
|
|
85
|
+
from transformers import AutoProcessor, MllamaForConditionalGeneration
|
|
86
|
+
except ImportError as e:
|
|
87
|
+
raise ImportError(
|
|
88
|
+
"Llama 3.2 Vision support requires transformers + torch + accelerate: "
|
|
89
|
+
"pip install 'multixtract[llama]'"
|
|
90
|
+
) from e
|
|
91
|
+
|
|
92
|
+
self.device = device or _infer_device()
|
|
93
|
+
load_kwargs = {"low_cpu_mem_usage": True}
|
|
94
|
+
if self.device == "cuda":
|
|
95
|
+
load_kwargs["torch_dtype"] = getattr(torch, torch_dtype, torch.bfloat16)
|
|
96
|
+
else:
|
|
97
|
+
load_kwargs["torch_dtype"] = torch.float32
|
|
98
|
+
if load_in_4bit:
|
|
99
|
+
load_kwargs["load_in_4bit"] = True
|
|
100
|
+
load_kwargs["device_map"] = "auto" # required by bitsandbytes 4-bit
|
|
101
|
+
else:
|
|
102
|
+
load_kwargs["device_map"] = self.device
|
|
103
|
+
|
|
104
|
+
self._processor = AutoProcessor.from_pretrained(model_id)
|
|
105
|
+
self._model = MllamaForConditionalGeneration.from_pretrained(model_id, **load_kwargs)
|
|
106
|
+
self._model.eval()
|
|
107
|
+
|
|
108
|
+
def analyze(
|
|
109
|
+
self,
|
|
110
|
+
image_bytes: bytes,
|
|
111
|
+
ext: str = "png",
|
|
112
|
+
width: int = 0,
|
|
113
|
+
height: int = 0,
|
|
114
|
+
) -> VisionResult:
|
|
115
|
+
"""Describe one image. Never raises — returns an empty result on failure."""
|
|
116
|
+
try:
|
|
117
|
+
import torch
|
|
118
|
+
|
|
119
|
+
with _open_image_rgb(image_bytes) as image:
|
|
120
|
+
messages = [
|
|
121
|
+
{
|
|
122
|
+
"role": "user",
|
|
123
|
+
"content": [
|
|
124
|
+
{"type": "image"},
|
|
125
|
+
{"type": "text", "text": self.system_prompt},
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
with self._lock:
|
|
130
|
+
input_text = self._processor.apply_chat_template(
|
|
131
|
+
messages, add_generation_prompt=True
|
|
132
|
+
)
|
|
133
|
+
inputs = self._processor(
|
|
134
|
+
image, input_text, return_tensors="pt"
|
|
135
|
+
).to(self._model.device)
|
|
136
|
+
with torch.inference_mode():
|
|
137
|
+
output_ids = self._model.generate(
|
|
138
|
+
**inputs,
|
|
139
|
+
max_new_tokens=self.max_new_tokens,
|
|
140
|
+
do_sample=False,
|
|
141
|
+
)
|
|
142
|
+
# Decode only the newly generated tokens (skip the prompt).
|
|
143
|
+
prompt_len = inputs["input_ids"].shape[-1]
|
|
144
|
+
text = self._processor.decode(
|
|
145
|
+
output_ids[0][prompt_len:], skip_special_tokens=True
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return parse_vision_response(text)
|
|
149
|
+
except Exception: # noqa: BLE001 — never break the caller/pipeline
|
|
150
|
+
log.warning("Llama32Vision analyze failed", exc_info=True)
|
|
151
|
+
return VisionResult()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""OpenAI providers (optional extra: ``pip install multixtract[openai]``).
|
|
2
|
+
|
|
3
|
+
Implements VisionModel and Embedder against the OpenAI Python SDK. The core
|
|
4
|
+
pipeline depends only on the interfaces — importing this module is the only
|
|
5
|
+
place ``openai`` is required.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from ..interfaces import Embedder, VisionModel, VisionResult
|
|
14
|
+
from ..vision import DEFAULT_SYSTEM_PROMPT, parse_vision_response, to_data_url
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("multixtract")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_permanent(exc: Exception) -> bool:
|
|
20
|
+
"""Return True for errors that will never succeed on retry."""
|
|
21
|
+
try:
|
|
22
|
+
from openai import AuthenticationError, BadRequestError, PermissionDeniedError
|
|
23
|
+
if isinstance(exc, (AuthenticationError, BadRequestError, PermissionDeniedError)):
|
|
24
|
+
return True
|
|
25
|
+
except ImportError:
|
|
26
|
+
pass
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _retry(func, *, label: str = "API", max_retries: int = 3):
|
|
31
|
+
"""Call *func()* with exponential backoff and rate-limit awareness."""
|
|
32
|
+
for attempt in range(max_retries):
|
|
33
|
+
try:
|
|
34
|
+
return func()
|
|
35
|
+
except Exception as exc: # noqa: BLE001
|
|
36
|
+
if attempt >= max_retries - 1 or _is_permanent(exc):
|
|
37
|
+
log.warning("%s failed after %d attempts: %s", label, attempt + 1, exc) # noqa: BLE001
|
|
38
|
+
raise
|
|
39
|
+
wait = 2 ** attempt
|
|
40
|
+
time.sleep(wait)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OpenAIVisionModel(VisionModel):
|
|
44
|
+
"""Vision provider backed by an OpenAI chat-completions vision model."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
api_key: Optional[str] = None,
|
|
49
|
+
model: str = "gpt-4o",
|
|
50
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
|
51
|
+
max_tokens: int = 800,
|
|
52
|
+
temperature: float = 0.1,
|
|
53
|
+
client=None,
|
|
54
|
+
) -> None:
|
|
55
|
+
if client is None:
|
|
56
|
+
from openai import OpenAI # local import keeps openai optional
|
|
57
|
+
client = OpenAI(api_key=api_key)
|
|
58
|
+
self._client = client
|
|
59
|
+
self.model = model
|
|
60
|
+
self.system_prompt = system_prompt
|
|
61
|
+
self.max_tokens = max_tokens
|
|
62
|
+
self.temperature = temperature
|
|
63
|
+
|
|
64
|
+
def analyze(self, image_bytes: bytes, ext: str = "png", width: int = 0, height: int = 0) -> VisionResult:
|
|
65
|
+
try:
|
|
66
|
+
data_url = to_data_url(image_bytes, ext, width, height)
|
|
67
|
+
resp = _retry(
|
|
68
|
+
lambda: self._client.chat.completions.create(
|
|
69
|
+
model=self.model,
|
|
70
|
+
messages=[
|
|
71
|
+
{"role": "system", "content": self.system_prompt},
|
|
72
|
+
{"role": "user", "content": [
|
|
73
|
+
{"type": "text", "text": "Analyze this image."},
|
|
74
|
+
{"type": "image_url", "image_url": {"url": data_url, "detail": "high"}},
|
|
75
|
+
]},
|
|
76
|
+
],
|
|
77
|
+
max_tokens=self.max_tokens,
|
|
78
|
+
temperature=self.temperature,
|
|
79
|
+
),
|
|
80
|
+
label="vision",
|
|
81
|
+
)
|
|
82
|
+
return parse_vision_response(resp.choices[0].message.content or "")
|
|
83
|
+
except Exception: # noqa: BLE001 — never break the pipeline
|
|
84
|
+
return VisionResult()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class OpenAIEmbedder(Embedder):
|
|
88
|
+
"""Embedding provider backed by an OpenAI embeddings model."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
api_key: Optional[str] = None,
|
|
93
|
+
model: str = "text-embedding-3-large",
|
|
94
|
+
dim: int = 1024,
|
|
95
|
+
batch_size: int = 16,
|
|
96
|
+
text_limit: int = 8000,
|
|
97
|
+
client=None,
|
|
98
|
+
) -> None:
|
|
99
|
+
if client is None:
|
|
100
|
+
from openai import OpenAI
|
|
101
|
+
client = OpenAI(api_key=api_key)
|
|
102
|
+
self._client = client
|
|
103
|
+
self.model = model
|
|
104
|
+
self.dim = dim
|
|
105
|
+
self.batch_size = batch_size
|
|
106
|
+
self.text_limit = text_limit
|
|
107
|
+
|
|
108
|
+
def embed(self, texts: List[str]) -> List[Optional[List[float]]]:
|
|
109
|
+
results: List[Optional[List[float]]] = [None] * len(texts)
|
|
110
|
+
work = [(original_index, text[: self.text_limit]) for original_index, text in enumerate(texts) if text]
|
|
111
|
+
for start in range(0, len(work), self.batch_size):
|
|
112
|
+
batch = work[start : start + self.batch_size]
|
|
113
|
+
inputs = [text for _, text in batch]
|
|
114
|
+
try:
|
|
115
|
+
resp = _retry(
|
|
116
|
+
lambda inp=inputs: self._client.embeddings.create(
|
|
117
|
+
model=self.model, input=inp, dimensions=self.dim,
|
|
118
|
+
),
|
|
119
|
+
label="embed",
|
|
120
|
+
)
|
|
121
|
+
for (original_index, _), item in zip(batch, resp.data):
|
|
122
|
+
results[original_index] = item.embedding
|
|
123
|
+
except Exception: # noqa: BLE001
|
|
124
|
+
pass # leave those entries as None
|
|
125
|
+
return results
|