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.
- doc_vision_parser-0.1.3.dist-info/METADATA +215 -0
- doc_vision_parser-0.1.3.dist-info/RECORD +18 -0
- doc_vision_parser-0.1.3.dist-info/WHEEL +4 -0
- doc_vision_parser-0.1.3.dist-info/licenses/LICENSE +201 -0
- docvision/__init__.py +4 -0
- docvision/__version__.py +1 -0
- docvision/core/__init__.py +19 -0
- docvision/core/client.py +223 -0
- docvision/core/parser.py +461 -0
- docvision/core/types.py +79 -0
- docvision/processing/__init__.py +4 -0
- docvision/processing/crop.py +147 -0
- docvision/processing/image.py +201 -0
- docvision/utils/__init__.py +13 -0
- docvision/utils/helper.py +103 -0
- docvision/workflows/__init__.py +17 -0
- docvision/workflows/graph.py +232 -0
- docvision/workflows/prompts.py +42 -0
docvision/core/client.py
ADDED
|
@@ -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
|
docvision/core/parser.py
ADDED
|
@@ -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
|
+
)
|