doc-vision-parser 0.1.3__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,223 @@
1
+ import asyncio
2
+ import os
3
+ import time
4
+ from typing import Any, Dict, List, Optional, Type
5
+
6
+ from openai import AsyncOpenAI, OpenAI
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class VLMClient:
11
+ """
12
+ A client for interacting with Vision Language Models (VLMs) via OpenAI-compatible APIs.
13
+ Supports both synchronous and asynchronous calls with automatic retries.
14
+
15
+ Attributes:
16
+ model_name: Name of the model to use.
17
+ max_tokens: Maximum number of tokens for the completion.
18
+ temperature: Sampling temperature.
19
+ timeout: Request timeout in seconds.
20
+ max_retries: Number of retry attempts.
21
+ retry_delay: Delay between retries in seconds.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ base_url: str = "https://api.openai.com/v1",
27
+ api_key: Optional[str] = None,
28
+ model_name: str = "gpt-4o-mini",
29
+ max_tokens: int = 2048,
30
+ temperature: float = 0.1,
31
+ timeout: int = 300,
32
+ max_retries: int = 3,
33
+ retry_delay: float = 2.0,
34
+ ):
35
+ """
36
+ Initialize the VLMClient.
37
+
38
+ Args:
39
+ base_url: The base URL for the API.
40
+ api_key: The API key. If not provided, it will look for the OPENAI_API_KEY environment variable.
41
+ model_name: The name of the model to use.
42
+ max_tokens: Maximum number of tokens to generate.
43
+ temperature: Sampling temperature.
44
+ timeout: Request timeout in seconds.
45
+ max_retries: Maximum number of retry attempts for failed requests.
46
+ retry_delay: Delay between retries in seconds.
47
+ """
48
+ self.model_name = model_name
49
+ self.max_tokens = max_tokens
50
+ self.temperature = temperature
51
+ self.timeout = timeout
52
+ self.max_retries = max_retries
53
+ self.retry_delay = retry_delay
54
+
55
+ # Ensure we have an API key or a placeholder for local servers
56
+ self.api_key = api_key or os.getenv("OPENAI_API_KEY", "EMPTY")
57
+
58
+ self.client = OpenAI(base_url=base_url, api_key=self.api_key, timeout=timeout)
59
+
60
+ self.async_client = AsyncOpenAI(base_url=base_url, api_key=self.api_key, timeout=timeout)
61
+
62
+ def call(
63
+ self,
64
+ image_b64: str,
65
+ mime_type: str,
66
+ system_prompt: Optional[str] = None,
67
+ user_prompt: Optional[str] = None,
68
+ output_schema: Optional[Type[BaseModel]] = None,
69
+ ) -> Any:
70
+ """
71
+ Make a synchronous call to the VLM.
72
+
73
+ Args:
74
+ image_b64: Base64 encoded image string.
75
+ mime_type: The MIME type of the image.
76
+ system_prompt: Optional system prompt override.
77
+ user_prompt: Optional user prompt override.
78
+ output_schema: Optional Pydantic model for structured output parsing.
79
+
80
+ Returns:
81
+ The API response object.
82
+
83
+ Raises:
84
+ RuntimeError: If the request fails after all retry attempts.
85
+ """
86
+ messages = self._build_message(
87
+ image_b64, mime_type, system_prompt, user_prompt, output_schema
88
+ )
89
+
90
+ for attempt in range(self.max_retries):
91
+ try:
92
+ if output_schema:
93
+ return self.client.chat.completions.parse(
94
+ model=self.model_name,
95
+ messages=messages,
96
+ max_tokens=self.max_tokens,
97
+ temperature=self.temperature,
98
+ response_format=output_schema,
99
+ )
100
+ else:
101
+ return self.client.chat.completions.create(
102
+ model=self.model_name,
103
+ messages=messages,
104
+ max_tokens=self.max_tokens,
105
+ temperature=self.temperature,
106
+ )
107
+
108
+ except Exception as e:
109
+ if attempt < self.max_retries - 1:
110
+ wait_time = self.retry_delay * (attempt + 1)
111
+ time.sleep(wait_time)
112
+ else:
113
+ raise RuntimeError(
114
+ f"VLM call failed after {self.max_retries} attempts: {str(e)}"
115
+ ) from e
116
+
117
+ async def acall(
118
+ self,
119
+ image_b64: str,
120
+ mime_type: str,
121
+ system_prompt: Optional[str] = None,
122
+ user_prompt: Optional[str] = None,
123
+ output_schema: Optional[Type[BaseModel]] = None,
124
+ ) -> Any:
125
+ """
126
+ Make an asynchronous call to the VLM.
127
+
128
+ Args:
129
+ image_b64: Base64 encoded image string.
130
+ mime_type: The MIME type of the image.
131
+ system_prompt: Optional system prompt override.
132
+ user_prompt: Optional user prompt override.
133
+ output_schema: Optional Pydantic model for structured output parsing.
134
+
135
+ Returns:
136
+ The API response object.
137
+
138
+ Raises:
139
+ RuntimeError: If the request fails after all retry attempts.
140
+ """
141
+ messages = self._build_message(
142
+ image_b64, mime_type, system_prompt, user_prompt, output_schema
143
+ )
144
+
145
+ for attempt in range(self.max_retries):
146
+ try:
147
+ if output_schema:
148
+ return await self.async_client.chat.completions.parse(
149
+ model=self.model_name,
150
+ messages=messages,
151
+ max_tokens=self.max_tokens,
152
+ temperature=self.temperature,
153
+ response_format=output_schema,
154
+ )
155
+ else:
156
+ return await self.async_client.chat.completions.create(
157
+ model=self.model_name,
158
+ messages=messages,
159
+ max_tokens=self.max_tokens,
160
+ temperature=self.temperature,
161
+ )
162
+
163
+ except Exception as e:
164
+ if attempt < self.max_retries - 1:
165
+ wait_time = self.retry_delay * (attempt + 1)
166
+ await asyncio.sleep(wait_time)
167
+ else:
168
+ raise RuntimeError(
169
+ f"Asynchronous VLM call failed after {self.max_retries} attempts: {str(e)}"
170
+ ) from e
171
+
172
+ def _build_message(
173
+ self,
174
+ image_b64: str,
175
+ mime_type: str,
176
+ system_prompt: Optional[str] = None,
177
+ user_prompt: Optional[str] = None,
178
+ output_schema: Optional[Type[BaseModel]] = None,
179
+ ) -> List[Dict[str, Any]]:
180
+ """
181
+ Construct the message payload for the API call.
182
+
183
+ Args:
184
+ image_b64: Base64 encoded image string.
185
+ mime_type: The MIME type of the image.
186
+ system_prompt: Optional system prompt override.
187
+ user_prompt: Optional user prompt override.
188
+ output_schema: Optional output schema.
189
+
190
+ Returns:
191
+ A list of message dictionaries.
192
+ """
193
+ messages = []
194
+
195
+ if output_schema is not None:
196
+ if not system_prompt:
197
+ raise ValueError(
198
+ "When using response_format, you MUST provide a system_prompt explicitly "
199
+ "(default XML prompt is disabled because it conflicts with structured output)."
200
+ )
201
+ final_system_prompt = system_prompt
202
+ else:
203
+ from ..workflows import DEFAULT_SYSTEM_PROMPT, TRANSCRIPTION
204
+
205
+ if system_prompt:
206
+ final_system_prompt = f"{system_prompt}\n\n{TRANSCRIPTION}"
207
+ else:
208
+ final_system_prompt = DEFAULT_SYSTEM_PROMPT
209
+
210
+ messages.append({"role": "system", "content": final_system_prompt})
211
+
212
+ from ..workflows import DEFAULT_USER_PROMPT
213
+
214
+ user_content = [
215
+ {"type": "text", "text": user_prompt or DEFAULT_USER_PROMPT},
216
+ {
217
+ "type": "image_url",
218
+ "image_url": {"url": f"data:{mime_type};base64,{image_b64}"},
219
+ },
220
+ ]
221
+
222
+ messages.append({"role": "user", "content": user_content})
223
+ return messages
@@ -0,0 +1,461 @@
1
+ import asyncio
2
+ import time
3
+ from pathlib import Path
4
+ from typing import Literal, Optional, Type, Union
5
+
6
+ from PIL import Image
7
+ from pydantic import BaseModel
8
+ from tqdm import tqdm
9
+ from tqdm.asyncio import tqdm as atqdm
10
+
11
+ from ..processing import ImageProcessor
12
+ from ..workflows import AgenticWorkflow
13
+ from ..workflows.prompts import DEFAULT_SYSTEM_PROMPT, DEFAULT_USER_PROMPT
14
+ from .client import VLMClient
15
+ from .types import BatchParseResult, ImageFormat, ParseResult, ParsingMode
16
+
17
+
18
+ class DocumentParsingAgent:
19
+ """
20
+ Production-ready document parser using Vision Language Models.
21
+
22
+ Supports two parsing modes:
23
+ - VLM: Fast single-shot parsing (default)
24
+ - AGENTIC: Self-correcting multi-turn workflow for maximum quality
25
+
26
+ Attributes:
27
+ image_format: Format to use for image encoding (png/jpeg).
28
+ jpeg_quality: Quality setting for JPEG encoding.
29
+ system_prompt: System prompt for transcription instructions.
30
+ user_prompt: Initial user prompt for transcription.
31
+ processor: ImageProcessor instance for PDF/Image handling.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ base_url: str = "https://api.openai.com/v1",
37
+ api_key: Optional[str] = None,
38
+ model_name: str = "gpt-4o-mini",
39
+ timeout: float = 300.0,
40
+ dpi: int = 300,
41
+ auto_crop: bool = False,
42
+ resize: bool = True,
43
+ max_dimension: int = 2048,
44
+ image_format: Literal["png", "jpeg"] = "jpeg",
45
+ jpeg_quality: int = 95,
46
+ crop_padding: int = 10,
47
+ crop_ignore_bottom_percent: float = 12.0,
48
+ crop_footer_gap_threshold: int = 100,
49
+ crop_column_ink_ratio: float = 0.01,
50
+ crop_row_ink_ratio: float = 0.002,
51
+ system_prompt: Optional[str] = None,
52
+ user_prompt: Optional[str] = None,
53
+ temperature: float = 0.1,
54
+ max_tokens: int = 2048,
55
+ debug_save_path: Optional[str] = None,
56
+ ):
57
+ """
58
+ Initialize the DocumentParsingAgent.
59
+
60
+ Args:
61
+ base_url: Base URL for OpenAI-compatible API.
62
+ api_key: API key for authentication.
63
+ model_name: Name of the VLM model.
64
+ timeout: Request timeout in seconds.
65
+ dpi: DPI for PDF to image conversion.
66
+ auto_crop: Enable content-aware cropping.
67
+ resize: Enable image resizing.
68
+ max_dimension: Max width/height for processed images.
69
+ image_format: Encoding format.
70
+ jpeg_quality: Quality for JPEG encoding.
71
+ crop_padding: Padding for cropper.
72
+ crop_ignore_bottom_percent: Footer ignore height %.
73
+ crop_footer_gap_threshold: Gap threshold for footer detection.
74
+ crop_column_ink_ratio: Column ink sensitivity.
75
+ crop_row_ink_ratio: Row ink sensitivity.
76
+ system_prompt: Custom system prompt.
77
+ user_prompt: Custom initial user prompt.
78
+ temperature: VLM sampling temperature.
79
+ max_tokens: Max tokens for VLM response.
80
+ debug_save_path: Directory to save debug images.
81
+ """
82
+ self.api_key = api_key or "EMPTY"
83
+ self.image_format = image_format
84
+ self.jpeg_quality = jpeg_quality
85
+ self.system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
86
+ self.user_prompt = user_prompt or DEFAULT_USER_PROMPT
87
+
88
+ self._vlm_client = VLMClient(
89
+ base_url=base_url,
90
+ api_key=self.api_key,
91
+ model_name=model_name,
92
+ timeout=int(timeout),
93
+ max_tokens=max_tokens,
94
+ temperature=temperature,
95
+ max_retries=3,
96
+ retry_delay=2.0,
97
+ )
98
+
99
+ self.processor = ImageProcessor(
100
+ dpi=dpi,
101
+ auto_crop=auto_crop,
102
+ resize=resize,
103
+ max_dimension=max_dimension,
104
+ crop_padding=crop_padding,
105
+ crop_ignore_bottom_percent=crop_ignore_bottom_percent,
106
+ crop_footer_gap_threshold=crop_footer_gap_threshold,
107
+ crop_column_ink_ratio=crop_column_ink_ratio,
108
+ crop_row_ink_ratio=crop_row_ink_ratio,
109
+ debug_save_path=debug_save_path,
110
+ )
111
+
112
+ self._agentic_workflow = None
113
+
114
+ def _get_agentic_workflow(self) -> AgenticWorkflow:
115
+ """Lazy initialization of agentic workflow."""
116
+ if self._agentic_workflow is None:
117
+ self._agentic_workflow = AgenticWorkflow(
118
+ vlm_client=self._vlm_client,
119
+ system_prompt=self.system_prompt,
120
+ user_prompt=self.user_prompt,
121
+ )
122
+ return self._agentic_workflow
123
+
124
+ def parse_image(
125
+ self,
126
+ image: Union[str, Path, Image.Image],
127
+ mode: ParsingMode = ParsingMode.VLM,
128
+ output_schema: Optional[Type[BaseModel]] = None,
129
+ ) -> ParseResult:
130
+ """
131
+ Parse a single image (Synchronous).
132
+
133
+ Args:
134
+ image: Path to image file or PIL Image object.
135
+ mode: Parsing mode (VLM or AGENTIC).
136
+ output_schema: Optional Pydantic model for structured output (VLM mode only).
137
+
138
+ Returns:
139
+ A ParseResult object containing the output and metadata.
140
+ """
141
+ if mode == ParsingMode.AGENTIC:
142
+ raise NotImplementedError("Agentic mode requires async. Use aparse_image() instead.")
143
+
144
+ start_time = time.time()
145
+
146
+ if isinstance(image, (str, Path)):
147
+ image = Image.open(image)
148
+
149
+ processed_img = self.processor.process_image(image, page_num=0, doc_name="image")
150
+
151
+ img_b64, mime_type = self.processor.encode_image(
152
+ processed_img, ImageFormat(self.image_format), self.jpeg_quality
153
+ )
154
+
155
+ response = self._vlm_client.call(
156
+ img_b64,
157
+ mime_type,
158
+ self.system_prompt,
159
+ self.user_prompt,
160
+ output_schema=output_schema,
161
+ )
162
+
163
+ content = response.choices[0].message.content if response and response.choices else ""
164
+ processing_time = time.time() - start_time
165
+
166
+ return ParseResult(
167
+ content=content,
168
+ page_number=0,
169
+ processing_time=processing_time,
170
+ metadata={
171
+ "image_size": processed_img.size,
172
+ "mime_type": mime_type,
173
+ "mode": mode.value,
174
+ "structured": bool(output_schema),
175
+ },
176
+ )
177
+
178
+ def parse_pdf(
179
+ self,
180
+ pdf_path: Union[str, Path],
181
+ mode: ParsingMode = ParsingMode.VLM,
182
+ start_page: Optional[int] = None,
183
+ end_page: Optional[int] = None,
184
+ output_schema: Optional[Type[BaseModel]] = None,
185
+ ) -> BatchParseResult:
186
+ """
187
+ Parse a PDF document page by page (Synchronous).
188
+
189
+ Args:
190
+ pdf_path: Path to the PDF file.
191
+ mode: Parsing mode (VLM or AGENTIC).
192
+ start_page: First page index to parse.
193
+ end_page: Last page index to parse.
194
+ output_schema: Optional Pydantic model for structured output (VLM mode only).
195
+
196
+ Returns:
197
+ A BatchParseResult containing results for all pages.
198
+ """
199
+ if mode == ParsingMode.AGENTIC:
200
+ raise NotImplementedError("Agentic mode requires async. Use aparse_pdf() instead.")
201
+
202
+ batch_start = time.time()
203
+
204
+ images = self.processor.pdf_to_images(pdf_path, start_page, end_page)
205
+ total_pages = len(images)
206
+
207
+ results = []
208
+ errors = []
209
+ success_count = 0
210
+
211
+ doc_name = Path(pdf_path).stem
212
+
213
+ for idx, img in tqdm(enumerate(images), total=total_pages, desc="Processing pages"):
214
+ page_num = (start_page or 0) + idx
215
+
216
+ try:
217
+ processed_img = self.processor.process_image(img, page_num, doc_name)
218
+ img_b64, mime_type = self.processor.encode_image(
219
+ processed_img, ImageFormat(self.image_format), self.jpeg_quality
220
+ )
221
+
222
+ page_start = time.time()
223
+ response = self._vlm_client.call(
224
+ img_b64,
225
+ mime_type,
226
+ self.system_prompt,
227
+ self.user_prompt,
228
+ output_schema=output_schema,
229
+ )
230
+
231
+ content = (
232
+ response.choices[0].message.content if response and response.choices else ""
233
+ )
234
+ processing_time = time.time() - page_start
235
+
236
+ result = ParseResult(
237
+ content=content,
238
+ page_number=page_num,
239
+ processing_time=processing_time,
240
+ metadata={
241
+ "image_size": processed_img.size,
242
+ "mime_type": mime_type,
243
+ "mode": mode.value,
244
+ "structured": bool(output_schema),
245
+ },
246
+ )
247
+
248
+ results.append(result)
249
+ success_count += 1
250
+
251
+ except Exception as e:
252
+ errors.append({"page": page_num, "error": str(e), "type": type(e).__name__})
253
+
254
+ total_time = time.time() - batch_start
255
+
256
+ return BatchParseResult(
257
+ results=results,
258
+ total_pages=total_pages,
259
+ total_time=total_time,
260
+ success_count=success_count,
261
+ error_count=len(errors),
262
+ errors=errors,
263
+ )
264
+
265
+ async def aparse_image(
266
+ self,
267
+ image: Union[str, Path, Image.Image],
268
+ mode: ParsingMode = ParsingMode.VLM,
269
+ output_schema: Optional[Type[BaseModel]] = None,
270
+ ) -> ParseResult:
271
+ """
272
+ Parse a single image (Asynchronous).
273
+
274
+ Args:
275
+ image: Path to image file or PIL Image object.
276
+ mode: Parsing mode (VLM or AGENTIC).
277
+ output_schema: Optional Pydantic model for structured output (VLM mode only).
278
+
279
+ Returns:
280
+ A ParseResult object.
281
+ """
282
+ if mode == ParsingMode.AGENTIC and output_schema is not None:
283
+ raise ValueError("output_schema is only supported in VLM mode")
284
+
285
+ start_time = time.time()
286
+
287
+ if isinstance(image, (str, Path)):
288
+ image = Image.open(image)
289
+
290
+ processed_img = self.processor.process_image(image, page_num=0, doc_name="image")
291
+
292
+ img_b64, mime_type = self.processor.encode_image(
293
+ processed_img, ImageFormat(self.image_format), self.jpeg_quality
294
+ )
295
+
296
+ if mode == ParsingMode.VLM:
297
+ response = await self._vlm_client.acall(
298
+ img_b64,
299
+ mime_type,
300
+ self.system_prompt,
301
+ self.user_prompt,
302
+ output_schema=output_schema,
303
+ )
304
+ content = response.choices[0].message.content if response and response.choices else ""
305
+ iterations = 1
306
+ generation_history = []
307
+
308
+ elif mode == ParsingMode.AGENTIC:
309
+ workflow = self._get_agentic_workflow()
310
+ result = await workflow.run(img_b64, mime_type)
311
+ content = result["accumulated_text"]
312
+ iterations = result["iteration_count"]
313
+ generation_history = result["generation_history"]
314
+
315
+ else:
316
+ raise ValueError(f"Invalid parsing mode: {mode}")
317
+
318
+ processing_time = time.time() - start_time
319
+
320
+ return ParseResult(
321
+ content=content,
322
+ page_number=0,
323
+ processing_time=processing_time,
324
+ metadata={
325
+ "image_size": processed_img.size,
326
+ "mime_type": mime_type,
327
+ "mode": mode.value,
328
+ "iterations": iterations,
329
+ "generation_history": generation_history,
330
+ "structured": bool(output_schema),
331
+ },
332
+ )
333
+
334
+ async def aparse_pdf(
335
+ self,
336
+ pdf_path: Union[str, Path],
337
+ mode: ParsingMode = ParsingMode.VLM,
338
+ start_page: Optional[int] = None,
339
+ end_page: Optional[int] = None,
340
+ output_schema: Optional[Type[BaseModel]] = None,
341
+ max_concurrent: int = 3,
342
+ ) -> BatchParseResult:
343
+ """
344
+ Parse a PDF document with optional concurrency (Asynchronous).
345
+
346
+ Args:
347
+ pdf_path: Path to the PDF file.
348
+ mode: Parsing mode (VLM or AGENTIC).
349
+ start_page: First page index to parse.
350
+ end_page: Last page index to parse.
351
+ output_schema: Optional Pydantic model for structured output (VLM mode only).
352
+ max_concurrent: Maximum number of concurrent page parsing tasks.
353
+
354
+ Returns:
355
+ A BatchParseResult object.
356
+ """
357
+ if mode == ParsingMode.AGENTIC and output_schema is not None:
358
+ raise ValueError("output_schema is only supported in VLM mode")
359
+
360
+ batch_start = time.time()
361
+
362
+ images = self.processor.pdf_to_images(pdf_path, start_page, end_page)
363
+ total_pages = len(images)
364
+
365
+ results = []
366
+ errors = []
367
+
368
+ semaphore = asyncio.Semaphore(max_concurrent)
369
+ doc_name = Path(pdf_path).stem
370
+
371
+ workflow = self._get_agentic_workflow() if mode == ParsingMode.AGENTIC else None
372
+
373
+ async def process_page(idx: int, img: Image.Image):
374
+ page_num = (start_page or 0) + idx
375
+
376
+ async with semaphore:
377
+ try:
378
+ processed_img = self.processor.process_image(img, page_num, doc_name)
379
+ img_b64, mime_type = self.processor.encode_image(
380
+ processed_img, ImageFormat(self.image_format), self.jpeg_quality
381
+ )
382
+
383
+ page_start = time.time()
384
+
385
+ if mode == ParsingMode.VLM:
386
+ response = await self._vlm_client.acall(
387
+ img_b64,
388
+ mime_type,
389
+ self.system_prompt,
390
+ self.user_prompt,
391
+ output_schema=output_schema,
392
+ )
393
+ content = (
394
+ response.choices[0].message.content
395
+ if response and response.choices
396
+ else ""
397
+ )
398
+ iterations = 1
399
+ generation_history = []
400
+
401
+ elif mode == ParsingMode.AGENTIC:
402
+ result = await workflow.run(img_b64, mime_type)
403
+ content = result["accumulated_text"]
404
+ iterations = result["iteration_count"]
405
+ generation_history = result["generation_history"]
406
+
407
+ else:
408
+ raise ValueError(f"Invalid mode: {mode.value}")
409
+
410
+ processing_time = time.time() - page_start
411
+
412
+ return ParseResult(
413
+ content=content,
414
+ page_number=page_num,
415
+ processing_time=processing_time,
416
+ metadata={
417
+ "image_size": processed_img.size,
418
+ "mime_type": mime_type,
419
+ "mode": mode.value,
420
+ "iterations": iterations,
421
+ "generation_history": generation_history,
422
+ "structured": bool(output_schema),
423
+ },
424
+ )
425
+
426
+ except Exception as e:
427
+ return {
428
+ "error": True,
429
+ "page": page_num,
430
+ "message": str(e),
431
+ "type": type(e).__name__,
432
+ }
433
+
434
+ tasks = [process_page(idx, img) for idx, img in enumerate(images)]
435
+ page_results = []
436
+ for coro in atqdm(asyncio.as_completed(tasks), total=len(tasks), desc="Processing pages"):
437
+ result = await coro
438
+ page_results.append(result)
439
+
440
+ for result in page_results:
441
+ if isinstance(result, dict) and result.get("error"):
442
+ errors.append(
443
+ {
444
+ "page": result["page"],
445
+ "error": result["message"],
446
+ "type": result["type"],
447
+ }
448
+ )
449
+ else:
450
+ results.append(result)
451
+
452
+ total_time = time.time() - batch_start
453
+
454
+ return BatchParseResult(
455
+ results=results,
456
+ total_pages=total_pages,
457
+ total_time=total_time,
458
+ success_count=len(results),
459
+ error_count=len(errors),
460
+ errors=errors,
461
+ )