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.
@@ -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