parse-bench 1.0.0__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.
Files changed (227) hide show
  1. parse_bench/__init__.py +3 -0
  2. parse_bench/analysis/__init__.py +6 -0
  3. parse_bench/analysis/aggregation_report.py +582 -0
  4. parse_bench/analysis/cli.py +472 -0
  5. parse_bench/analysis/comparison.py +382 -0
  6. parse_bench/analysis/comparison_core.py +357 -0
  7. parse_bench/analysis/comparison_report.py +2066 -0
  8. parse_bench/analysis/detailed_report.py +2254 -0
  9. parse_bench/analysis/leaderboard_report.py +852 -0
  10. parse_bench/analysis/metric_definitions.py +771 -0
  11. parse_bench/cli.py +267 -0
  12. parse_bench/data/__init__.py +1 -0
  13. parse_bench/data/cli.py +118 -0
  14. parse_bench/data/download.py +127 -0
  15. parse_bench/evaluation/__init__.py +11 -0
  16. parse_bench/evaluation/cli.py +435 -0
  17. parse_bench/evaluation/evaluators/__init__.py +17 -0
  18. parse_bench/evaluation/evaluators/base.py +34 -0
  19. parse_bench/evaluation/evaluators/extract.py +429 -0
  20. parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
  21. parse_bench/evaluation/evaluators/parse.py +1353 -0
  22. parse_bench/evaluation/evaluators/qa.py +199 -0
  23. parse_bench/evaluation/layout_adapters/__init__.py +21 -0
  24. parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
  25. parse_bench/evaluation/layout_adapters/base.py +105 -0
  26. parse_bench/evaluation/layout_adapters/registry.py +109 -0
  27. parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
  28. parse_bench/evaluation/layout_label_mappers/base.py +66 -0
  29. parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
  30. parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
  31. parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
  32. parse_bench/evaluation/metric_aggregation.py +56 -0
  33. parse_bench/evaluation/metrics/__init__.py +5 -0
  34. parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
  35. parse_bench/evaluation/metrics/attribution/constants.py +12 -0
  36. parse_bench/evaluation/metrics/attribution/core.py +1108 -0
  37. parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
  38. parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
  39. parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
  40. parse_bench/evaluation/metrics/base.py +33 -0
  41. parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
  42. parse_bench/evaluation/metrics/extract/__init__.py +29 -0
  43. parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
  44. parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
  45. parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
  46. parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
  47. parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
  48. parse_bench/evaluation/metrics/extract/test_types.py +11 -0
  49. parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
  50. parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
  51. parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
  52. parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
  53. parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
  54. parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
  55. parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
  56. parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
  57. parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
  58. parse_bench/evaluation/metrics/parse/__init__.py +5 -0
  59. parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
  60. parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
  61. parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
  62. parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
  63. parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
  64. parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
  65. parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
  66. parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
  67. parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
  68. parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
  69. parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
  70. parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
  71. parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
  72. parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
  73. parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
  74. parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
  75. parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
  76. parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
  77. parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
  78. parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
  79. parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
  80. parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
  81. parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
  82. parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
  83. parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
  84. parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
  85. parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
  86. parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
  87. parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
  88. parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
  89. parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
  90. parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
  91. parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
  92. parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
  93. parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
  94. parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
  95. parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
  96. parse_bench/evaluation/metrics/parse/test_types.py +103 -0
  97. parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
  98. parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
  99. parse_bench/evaluation/metrics/parse/utils.py +885 -0
  100. parse_bench/evaluation/metrics/qa/__init__.py +5 -0
  101. parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
  102. parse_bench/evaluation/qa/__init__.py +5 -0
  103. parse_bench/evaluation/qa/llm_service.py +335 -0
  104. parse_bench/evaluation/reports/__init__.py +8 -0
  105. parse_bench/evaluation/reports/csv.py +64 -0
  106. parse_bench/evaluation/reports/html.py +338 -0
  107. parse_bench/evaluation/reports/markdown.py +98 -0
  108. parse_bench/evaluation/reports/rule_csv.py +22 -0
  109. parse_bench/evaluation/runner.py +1864 -0
  110. parse_bench/evaluation/stats.py +104 -0
  111. parse_bench/extensions.py +72 -0
  112. parse_bench/inference/__init__.py +33 -0
  113. parse_bench/inference/chunkr_layout_extraction.py +160 -0
  114. parse_bench/inference/cli.py +484 -0
  115. parse_bench/inference/layout_extraction.py +422 -0
  116. parse_bench/inference/pipelines/__init__.py +59 -0
  117. parse_bench/inference/pipelines/extract.py +39 -0
  118. parse_bench/inference/pipelines/layout.py +142 -0
  119. parse_bench/inference/pipelines/parse.py +2603 -0
  120. parse_bench/inference/pipelines.py +0 -0
  121. parse_bench/inference/providers/__init__.py +28 -0
  122. parse_bench/inference/providers/base.py +196 -0
  123. parse_bench/inference/providers/cancellation.py +137 -0
  124. parse_bench/inference/providers/extract/__init__.py +22 -0
  125. parse_bench/inference/providers/extract/citations.py +549 -0
  126. parse_bench/inference/providers/extract/extend.py +851 -0
  127. parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
  128. parse_bench/inference/providers/layoutdet/__init__.py +25 -0
  129. parse_bench/inference/providers/layoutdet/adapters.py +946 -0
  130. parse_bench/inference/providers/layoutdet/base.py +203 -0
  131. parse_bench/inference/providers/layoutdet/chandra.py +449 -0
  132. parse_bench/inference/providers/layoutdet/docling.py +125 -0
  133. parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
  134. parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
  135. parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
  136. parse_bench/inference/providers/layoutdet/paddle.py +117 -0
  137. parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
  138. parse_bench/inference/providers/layoutdet/surya.py +250 -0
  139. parse_bench/inference/providers/layoutdet/yolo.py +109 -0
  140. parse_bench/inference/providers/parse/__init__.py +64 -0
  141. parse_bench/inference/providers/parse/_docling_common.py +233 -0
  142. parse_bench/inference/providers/parse/_layout_utils.py +611 -0
  143. parse_bench/inference/providers/parse/amazon_nova.py +515 -0
  144. parse_bench/inference/providers/parse/anthropic.py +882 -0
  145. parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
  146. parse_bench/inference/providers/parse/chandra2.py +633 -0
  147. parse_bench/inference/providers/parse/chunkr.py +268 -0
  148. parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
  149. parse_bench/inference/providers/parse/datalab.py +370 -0
  150. parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
  151. parse_bench/inference/providers/parse/docling.py +281 -0
  152. parse_bench/inference/providers/parse/docling_serve.py +289 -0
  153. parse_bench/inference/providers/parse/dots_ocr.py +574 -0
  154. parse_bench/inference/providers/parse/extend_parse.py +710 -0
  155. parse_bench/inference/providers/parse/falconocr.py +436 -0
  156. parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
  157. parse_bench/inference/providers/parse/gemma4.py +472 -0
  158. parse_bench/inference/providers/parse/glm_zai.py +229 -0
  159. parse_bench/inference/providers/parse/google.py +1125 -0
  160. parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
  161. parse_bench/inference/providers/parse/google_docai.py +776 -0
  162. parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
  163. parse_bench/inference/providers/parse/granite_vision.py +515 -0
  164. parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
  165. parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
  166. parse_bench/inference/providers/parse/landingai.py +452 -0
  167. parse_bench/inference/providers/parse/liteparse.py +350 -0
  168. parse_bench/inference/providers/parse/llamaparse.py +677 -0
  169. parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
  170. parse_bench/inference/providers/parse/markitdown.py +138 -0
  171. parse_bench/inference/providers/parse/mineru25.py +405 -0
  172. parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
  173. parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
  174. parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
  175. parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
  176. parse_bench/inference/providers/parse/oi_parser.py +222 -0
  177. parse_bench/inference/providers/parse/openai.py +740 -0
  178. parse_bench/inference/providers/parse/opendataloader.py +152 -0
  179. parse_bench/inference/providers/parse/paddleocr.py +624 -0
  180. parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
  181. parse_bench/inference/providers/parse/pulse.py +785 -0
  182. parse_bench/inference/providers/parse/pymupdf.py +207 -0
  183. parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
  184. parse_bench/inference/providers/parse/pypdf.py +179 -0
  185. parse_bench/inference/providers/parse/qwen.py +678 -0
  186. parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
  187. parse_bench/inference/providers/parse/reducto.py +546 -0
  188. parse_bench/inference/providers/parse/surya2.py +372 -0
  189. parse_bench/inference/providers/parse/tesseract.py +301 -0
  190. parse_bench/inference/providers/parse/textract.py +694 -0
  191. parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
  192. parse_bench/inference/providers/parse/unstructured.py +485 -0
  193. parse_bench/inference/providers/parse/warp_ingest.py +199 -0
  194. parse_bench/inference/providers/registry.py +49 -0
  195. parse_bench/inference/renormalize.py +170 -0
  196. parse_bench/inference/runner.py +2023 -0
  197. parse_bench/layout_label_mapping.py +424 -0
  198. parse_bench/layout_projection.py +179 -0
  199. parse_bench/pipeline/__init__.py +1 -0
  200. parse_bench/pipeline/cli.py +549 -0
  201. parse_bench/schemas/__init__.py +33 -0
  202. parse_bench/schemas/evaluation.py +93 -0
  203. parse_bench/schemas/extract_output.py +36 -0
  204. parse_bench/schemas/layout_detection_output.py +545 -0
  205. parse_bench/schemas/layout_ontology.py +315 -0
  206. parse_bench/schemas/metrics.py +69 -0
  207. parse_bench/schemas/parse_output.py +152 -0
  208. parse_bench/schemas/pipeline.py +22 -0
  209. parse_bench/schemas/pipeline_io.py +106 -0
  210. parse_bench/schemas/product.py +97 -0
  211. parse_bench/test_cases/__init__.py +25 -0
  212. parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
  213. parse_bench/test_cases/extract_field_paths.py +164 -0
  214. parse_bench/test_cases/layout_attribution_generation.py +287 -0
  215. parse_bench/test_cases/loader.py +652 -0
  216. parse_bench/test_cases/parse_rule_schemas.py +1071 -0
  217. parse_bench/test_cases/rule_filters.py +32 -0
  218. parse_bench/test_cases/rule_ids.py +107 -0
  219. parse_bench/test_cases/schema.py +427 -0
  220. parse_bench/utils/__init__.py +15 -0
  221. parse_bench/utils/gemini_layout_utils.py +670 -0
  222. parse_bench/utils/text_aggregation.py +100 -0
  223. parse_bench-1.0.0.dist-info/METADATA +476 -0
  224. parse_bench-1.0.0.dist-info/RECORD +227 -0
  225. parse_bench-1.0.0.dist-info/WHEEL +4 -0
  226. parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
  227. parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,382 @@
1
+ """Provider for DeepSeek-OCR-2 Modal server.
2
+
3
+ DeepSeek-OCR-2 (deepseek-ai/DeepSeek-OCR-2) is a MoE vision-language model
4
+ that handles layout detection + OCR in a single pass via the <|grounding|> token.
5
+
6
+ API format: POST /predict with {"image_base64": "..."} → {"markdown": "...", "status": "success"}
7
+ """
8
+
9
+ import asyncio
10
+ import base64
11
+ import io
12
+ import os
13
+ import re
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import aiohttp
19
+
20
+ from parse_bench.inference.providers.base import (
21
+ Provider,
22
+ ProviderConfigError,
23
+ ProviderPermanentError,
24
+ ProviderTransientError,
25
+ )
26
+ from parse_bench.inference.providers.parse._layout_utils import build_layout_pages
27
+ from parse_bench.inference.providers.registry import register_provider
28
+ from parse_bench.schemas.parse_output import ParseOutput
29
+ from parse_bench.schemas.pipeline import PipelineSpec
30
+ from parse_bench.schemas.pipeline_io import (
31
+ InferenceRequest,
32
+ InferenceResult,
33
+ RawInferenceResult,
34
+ )
35
+ from parse_bench.schemas.product import ProductType
36
+
37
+
38
+ @register_provider("deepseekocr2")
39
+ class DeepSeekOCR2Provider(Provider):
40
+ """
41
+ Provider for DeepSeek-OCR-2 Modal server.
42
+
43
+ Configuration options:
44
+ - server_url (str, required): Modal server predict endpoint URL
45
+ - timeout (int, default=600): Request timeout in seconds
46
+ - dpi (int, default=150): DPI for PDF to image conversion
47
+ """
48
+
49
+ def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
50
+ super().__init__(provider_name, base_config)
51
+
52
+ server_url = self.base_config.get("server_url") or os.getenv("DEEPSEEKOCR2_SERVER_URL")
53
+ if not server_url:
54
+ raise ProviderConfigError("DeepSeekOCR2 provider requires 'server_url' in config.")
55
+ self._server_url: str = server_url
56
+
57
+ self._timeout = self.base_config.get("timeout", 600)
58
+ self._dpi = self.base_config.get("dpi", 150)
59
+
60
+ def _pdf_to_images(self, pdf_path: Path) -> list[bytes]:
61
+ """Render every PDF page to PNG bytes in source order."""
62
+ try:
63
+ from pdf2image import convert_from_path
64
+
65
+ images = convert_from_path(pdf_path, dpi=self._dpi)
66
+ if not images:
67
+ raise ProviderPermanentError(f"No pages found in PDF: {pdf_path}")
68
+ encoded: list[bytes] = []
69
+ for image in images:
70
+ buf = io.BytesIO()
71
+ image.save(buf, format="PNG")
72
+ encoded.append(buf.getvalue())
73
+ return encoded
74
+ except ImportError as e:
75
+ raise ProviderPermanentError("pdf2image is required.") from e
76
+ except Exception as e:
77
+ if "pdf2image" in str(e).lower():
78
+ raise
79
+ raise ProviderPermanentError(f"Error converting PDF to image: {e}") from e
80
+
81
+ def _read_image(self, file_path: Path) -> bytes:
82
+ try:
83
+ return file_path.read_bytes()
84
+ except Exception as e:
85
+ raise ProviderPermanentError(f"Error reading image file: {e}") from e
86
+
87
+ async def _call_api(self, session: aiohttp.ClientSession, image_b64: str) -> dict[str, Any]:
88
+ api_url = self._server_url.rstrip("/")
89
+
90
+ payload: dict[str, str] = {"image_base64": image_b64}
91
+
92
+ async with session.post(
93
+ api_url,
94
+ json=payload,
95
+ headers={"Content-Type": "application/json"},
96
+ timeout=aiohttp.ClientTimeout(total=self._timeout),
97
+ ) as resp:
98
+ if resp.status != 200:
99
+ error_text = await resp.text()
100
+ if resp.status in (408, 502, 503, 504):
101
+ raise ProviderTransientError(f"HTTP {resp.status}: {error_text[:200]}")
102
+ raise ProviderPermanentError(f"HTTP {resp.status}: {error_text[:200]}")
103
+
104
+ result: dict[str, Any] = await resp.json()
105
+ if result.get("status") == "error":
106
+ raise ProviderPermanentError(result.get("error", "Unknown error from API"))
107
+
108
+ content: str = result.get("markdown", "")
109
+ if not content:
110
+ raise ProviderPermanentError("Empty markdown response from API")
111
+ return result
112
+
113
+ async def _run_inference_async(self, image_bytes: bytes) -> dict[str, Any]:
114
+ image_b64 = base64.b64encode(image_bytes).decode()
115
+
116
+ async with aiohttp.ClientSession() as session:
117
+ result = await self._call_api(session, image_b64)
118
+
119
+ return {
120
+ "markdown": result.get("markdown", ""),
121
+ "grounding_items": result.get("grounding_items", []),
122
+ "image_width": result.get("image_width"),
123
+ "image_height": result.get("image_height"),
124
+ "_config": {
125
+ "server_url": self._server_url,
126
+ "dpi": self._dpi,
127
+ },
128
+ }
129
+
130
+ async def _run_inference_pages_async(self, pages: list[bytes]) -> dict[str, Any]:
131
+ """Run each input page in order, retaining the legacy one-page shape."""
132
+ results = [await self._run_inference_async(page) for page in pages]
133
+ first = results[0]
134
+ if len(results) == 1:
135
+ return first
136
+ merged = dict(first)
137
+ merged["page_results"] = results
138
+ return merged
139
+
140
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
141
+ if request.product_type != ProductType.PARSE:
142
+ raise ProviderPermanentError(
143
+ f"DeepSeekOCR2Provider only supports PARSE product type, got {request.product_type}"
144
+ )
145
+
146
+ started_at = datetime.now()
147
+
148
+ file_path = Path(request.source_file_path)
149
+ if not file_path.exists():
150
+ raise ProviderPermanentError(f"Source file not found: {file_path}")
151
+
152
+ suffix = file_path.suffix.lower()
153
+ if suffix == ".pdf":
154
+ page_images = self._pdf_to_images(file_path)
155
+ elif suffix in (".png", ".jpg", ".jpeg", ".webp", ".tiff", ".bmp"):
156
+ page_images = [self._read_image(file_path)]
157
+ else:
158
+ raise ProviderPermanentError(
159
+ f"Unsupported file type: {suffix}. Supported: .pdf, .png, .jpg, .jpeg, .webp, .tiff, .bmp"
160
+ )
161
+
162
+ try:
163
+ raw_output = asyncio.run(self._run_inference_pages_async(page_images))
164
+ completed_at = datetime.now()
165
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
166
+
167
+ return RawInferenceResult(
168
+ request=request,
169
+ pipeline=pipeline,
170
+ pipeline_name=pipeline.pipeline_name,
171
+ product_type=request.product_type,
172
+ raw_output=raw_output,
173
+ started_at=started_at,
174
+ completed_at=completed_at,
175
+ latency_in_ms=latency_ms,
176
+ )
177
+
178
+ except (ProviderPermanentError, ProviderTransientError):
179
+ raise
180
+
181
+ except Exception as e:
182
+ completed_at = datetime.now()
183
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
184
+
185
+ error_msg = str(e)
186
+ if isinstance(e, asyncio.TimeoutError):
187
+ error_msg = f"Request timed out after {self._timeout} seconds"
188
+
189
+ return RawInferenceResult(
190
+ request=request,
191
+ pipeline=pipeline,
192
+ pipeline_name=pipeline.pipeline_name,
193
+ product_type=request.product_type,
194
+ raw_output={
195
+ "markdown": "",
196
+ "_error": error_msg,
197
+ "_error_type": type(e).__name__,
198
+ "_config": {
199
+ "server_url": self._server_url,
200
+ "dpi": self._dpi,
201
+ },
202
+ },
203
+ started_at=started_at,
204
+ completed_at=completed_at,
205
+ latency_in_ms=latency_ms,
206
+ )
207
+
208
+ @staticmethod
209
+ def _close_unclosed_table_tags(content: str) -> str:
210
+ """Auto-close unclosed HTML table tags from truncated model output."""
211
+ opens = content.count("<table>")
212
+ closes = content.count("</table>")
213
+ if opens > closes:
214
+ # Close any unclosed row/cell tags, then close the table
215
+ if content.rstrip().endswith(">"):
216
+ pass # last tag is already closed
217
+ else:
218
+ # Truncated mid-cell — close the cell and row
219
+ content += "</td></tr>"
220
+ content += "</table>" * (opens - closes)
221
+ return content
222
+
223
+ @staticmethod
224
+ def _promote_first_row_to_thead(content: str) -> str:
225
+ """Wrap the first <tr> of each <table> in <thead> and convert <td> to <th>.
226
+
227
+ The grounding model outputs all cells as <td>, never using <th>/<thead>.
228
+ This heuristic promotes the first row to a header row, matching how
229
+ markdown2 handles pipe tables and improving header metric scores.
230
+ """
231
+
232
+ def _promote_table(match: re.Match[str]) -> str:
233
+ table_html: str = match.group(0)
234
+ # Find the first <tr>...</tr>
235
+ first_tr = re.search(r"<tr>(.*?)</tr>", table_html, re.DOTALL)
236
+ if not first_tr:
237
+ return table_html
238
+ first_tr_full: str = first_tr.group(0)
239
+ first_tr_inner: str = first_tr.group(1)
240
+ # Convert <td> to <th> in the first row
241
+ header_inner = first_tr_inner.replace("<td>", "<th>").replace("</td>", "</th>")
242
+ # Also handle <td with attributes
243
+ header_inner = re.sub(r"<td(\s)", r"<th\1", header_inner)
244
+ header_inner = re.sub(r"</td>", "</th>", header_inner)
245
+ thead = f"<thead><tr>{header_inner}</tr></thead>"
246
+ # Replace first <tr> with <thead> block
247
+ table_html = table_html.replace(first_tr_full, thead, 1)
248
+ return table_html
249
+
250
+ return re.sub(r"<table>.*?</table>", _promote_table, content, flags=re.DOTALL)
251
+
252
+ @staticmethod
253
+ def _sanitize_html_attributes(markdown: str) -> str:
254
+ """Quote unquoted HTML attributes for XML-based metric parsers."""
255
+
256
+ def _quote_attrs(match: re.Match) -> str:
257
+ tag_text = match.group(0)
258
+ tag_text = re.sub(
259
+ r'(\w+)=([^\s"\'<>=]+)',
260
+ r'\1="\2"',
261
+ tag_text,
262
+ )
263
+ return tag_text
264
+
265
+ return re.sub(r"<[^>]+>", _quote_attrs, markdown)
266
+
267
+ @staticmethod
268
+ def _convert_md_tables_to_html(content: str) -> str:
269
+ """Convert markdown pipe tables to HTML for GriTS/TEDS metrics."""
270
+ import markdown2
271
+
272
+ lines = content.split("\n")
273
+ result_parts: list[str] = []
274
+ table_lines: list[str] = []
275
+ in_table = False
276
+
277
+ for line in lines:
278
+ is_table_line = "|" in line and line.strip().startswith("|")
279
+ if is_table_line:
280
+ if not in_table:
281
+ in_table = True
282
+ table_lines = [line]
283
+ else:
284
+ table_lines.append(line)
285
+ else:
286
+ if in_table:
287
+ if len(table_lines) >= 2:
288
+ table_md = "\n".join(table_lines)
289
+ html = markdown2.markdown(table_md, extras=["tables"]).strip()
290
+ if "<table>" in html.lower():
291
+ result_parts.append(html)
292
+ else:
293
+ result_parts.extend(table_lines)
294
+ else:
295
+ result_parts.extend(table_lines)
296
+ table_lines = []
297
+ in_table = False
298
+ result_parts.append(line)
299
+
300
+ if in_table and len(table_lines) >= 2:
301
+ table_md = "\n".join(table_lines)
302
+ html = markdown2.markdown(table_md, extras=["tables"]).strip()
303
+ if "<table>" in html.lower():
304
+ result_parts.append(html)
305
+ else:
306
+ result_parts.extend(table_lines)
307
+ elif in_table:
308
+ result_parts.extend(table_lines)
309
+
310
+ return "\n".join(result_parts)
311
+
312
+ # Grounding label aliases -> the names the shared _layout_utils.LABEL_MAP
313
+ # understands (title/table/caption/footnote already match; these three differ).
314
+ _LABEL_ALIASES: dict[str, str] = {
315
+ "header": "page-header",
316
+ "footer": "page-footer",
317
+ "image": "picture",
318
+ }
319
+
320
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
321
+ if raw_result.product_type != ProductType.PARSE:
322
+ raise ProviderPermanentError(
323
+ f"DeepSeekOCR2Provider only supports PARSE product type, got {raw_result.product_type}"
324
+ )
325
+
326
+ page_results = raw_result.raw_output.get("page_results")
327
+ if not isinstance(page_results, list) or not page_results:
328
+ page_results = [raw_result.raw_output]
329
+
330
+ page_markdowns: list[str] = []
331
+ layout_pages = []
332
+ for page_number, page_raw in enumerate(page_results, start=1):
333
+ markdown = page_raw.get("markdown", "")
334
+ if markdown:
335
+ # Auto-close unclosed HTML table tags (model truncates at max_tokens)
336
+ markdown = self._close_unclosed_table_tags(markdown)
337
+ markdown = self._convert_md_tables_to_html(markdown)
338
+ # Promote first row to <thead>/<th> (model outputs all <td>)
339
+ markdown = self._promote_first_row_to_thead(markdown)
340
+ markdown = self._sanitize_html_attributes(markdown)
341
+ page_markdowns.append(markdown)
342
+
343
+ # Build each page's layout from its grounding items. Grounding bboxes
344
+ # are [x1,y1,x2,y2] on a 0-999 grid; aliases map labels onto LABEL_MAP.
345
+ grounding_items = page_raw.get("grounding_items", [])
346
+ layout_items = [
347
+ {
348
+ "label": self._LABEL_ALIASES.get(str(gi.get("label", "")).lower(), gi.get("label", "")),
349
+ "bbox": gi.get("bbox", []),
350
+ }
351
+ for gi in grounding_items
352
+ ]
353
+ layout_pages.extend(
354
+ build_layout_pages(
355
+ items=layout_items,
356
+ image_width=page_raw.get("image_width", 0),
357
+ image_height=page_raw.get("image_height", 0),
358
+ markdown=markdown,
359
+ page_number=page_number,
360
+ )
361
+ )
362
+ markdown = "\n\n".join(page_markdowns)
363
+
364
+ output = ParseOutput(
365
+ task_type="parse",
366
+ example_id=raw_result.request.example_id,
367
+ pipeline_name=raw_result.pipeline_name,
368
+ pages=[],
369
+ markdown=markdown,
370
+ layout_pages=layout_pages,
371
+ )
372
+
373
+ return InferenceResult(
374
+ request=raw_result.request,
375
+ pipeline_name=raw_result.pipeline_name,
376
+ product_type=raw_result.product_type,
377
+ raw_output=raw_result.raw_output,
378
+ output=output,
379
+ started_at=raw_result.started_at,
380
+ completed_at=raw_result.completed_at,
381
+ latency_in_ms=raw_result.latency_in_ms,
382
+ )
@@ -0,0 +1,281 @@
1
+ """Provider for Docling parse via a custom HTTP endpoint."""
2
+
3
+ import base64
4
+ import os
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import requests
10
+ from docling_core.types.doc.document import DoclingDocument
11
+
12
+ from parse_bench.inference.providers.base import (
13
+ Provider,
14
+ ProviderConfigError,
15
+ ProviderPermanentError,
16
+ ProviderRateLimitError,
17
+ ProviderTransientError,
18
+ )
19
+ from parse_bench.inference.providers.parse._docling_common import _build_docling_layout_pages
20
+ from parse_bench.inference.providers.registry import register_provider
21
+ from parse_bench.schemas.parse_output import (
22
+ PageIR,
23
+ ParseLayoutPageIR,
24
+ ParseOutput,
25
+ )
26
+ from parse_bench.schemas.pipeline import PipelineSpec
27
+ from parse_bench.schemas.pipeline_io import (
28
+ InferenceRequest,
29
+ InferenceResult,
30
+ RawInferenceResult,
31
+ )
32
+ from parse_bench.schemas.product import ProductType
33
+
34
+
35
+ @register_provider("docling_parse")
36
+ class DoclingParseProvider(Provider):
37
+ """
38
+ Provider for Docling PDF parsing via a custom HTTP endpoint.
39
+
40
+ This provider sends PDFs to a Docling endpoint and returns markdown
41
+ with tables formatted as HTML.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ provider_name: str,
47
+ base_config: dict[str, Any] | None = None,
48
+ ):
49
+ """
50
+ Initialize the Docling parse provider.
51
+
52
+ Args:
53
+ provider_name: Name of the provider
54
+ base_config: Optional configuration with:
55
+ - `api_key`: Optional bearer token for the endpoint
56
+ - `hf_token`: Deprecated alias for `api_key`
57
+ - `endpoint_url`: Endpoint URL (required)
58
+ - `timeout`: Request timeout in seconds (default: 120)
59
+ """
60
+ super().__init__(provider_name, base_config)
61
+
62
+ # Optional bearer token; keep `hf_token` / `HF_TOKEN` as a deprecated
63
+ # fallback for backwards compatibility with the previous HF deployment.
64
+ self._api_key = (
65
+ self.base_config.get("api_key")
66
+ or self.base_config.get("hf_token")
67
+ or os.getenv("DOCLING_PARSE_API_KEY")
68
+ or os.getenv("HF_TOKEN")
69
+ or ""
70
+ )
71
+
72
+ # Get endpoint URL (from config or env var)
73
+ self._endpoint_url = self.base_config.get("endpoint_url") or os.getenv("DOCLING_PARSE_ENDPOINT_URL")
74
+ if not self._endpoint_url:
75
+ raise ProviderConfigError(
76
+ "Docling endpoint URL is required. "
77
+ "Set DOCLING_PARSE_ENDPOINT_URL environment variable or "
78
+ "pass endpoint_url in pipeline config."
79
+ )
80
+
81
+ # Get timeout (default 120 seconds - PDF processing can be slow)
82
+ self._timeout = self.base_config.get("timeout", 120)
83
+
84
+ def _call_endpoint(self, pdf_bytes: bytes) -> dict[str, Any]:
85
+ """
86
+ Call the Docling endpoint with PDF bytes.
87
+
88
+ Args:
89
+ pdf_bytes: Raw PDF file bytes
90
+
91
+ Returns:
92
+ Raw JSON response from endpoint
93
+
94
+ Raises:
95
+ ProviderError: For any API errors
96
+ """
97
+ headers = {"Content-Type": "application/json"}
98
+ if self._api_key:
99
+ headers["Authorization"] = f"Bearer {self._api_key}"
100
+
101
+ # Encode PDF as base64
102
+ pdf_base64 = base64.b64encode(pdf_bytes).decode("utf-8")
103
+
104
+ payload = {
105
+ "inputs": {
106
+ "pdf_base64": pdf_base64,
107
+ }
108
+ }
109
+
110
+ try:
111
+ response = requests.post(
112
+ self._endpoint_url,
113
+ headers=headers,
114
+ json=payload,
115
+ timeout=self._timeout,
116
+ )
117
+ response.raise_for_status()
118
+ result_json = response.json()
119
+ if isinstance(result_json, list):
120
+ if not result_json:
121
+ raise ProviderPermanentError("Endpoint returned an empty list response.")
122
+ first_result = result_json[0]
123
+ if not isinstance(first_result, dict):
124
+ raise ProviderPermanentError("Endpoint returned a list response with a non-dict payload.")
125
+ result = first_result
126
+ elif isinstance(result_json, dict):
127
+ result = result_json
128
+ else:
129
+ raise ProviderPermanentError(
130
+ f"Endpoint returned unsupported response type: {type(result_json).__name__}"
131
+ )
132
+ return result
133
+
134
+ except requests.exceptions.Timeout as e:
135
+ raise ProviderTransientError(f"Request timed out: {e}") from e
136
+ except requests.exceptions.ConnectionError as e:
137
+ raise ProviderTransientError(f"Connection error: {e}") from e
138
+ except requests.exceptions.HTTPError as e:
139
+ status_code = e.response.status_code if e.response else None
140
+ if status_code == 429:
141
+ raise ProviderRateLimitError(f"Rate limit exceeded: {e}") from e
142
+ elif status_code and 500 <= status_code < 600:
143
+ raise ProviderTransientError(f"Server error ({status_code}): {e}") from e
144
+ elif status_code and 400 <= status_code < 500:
145
+ raise ProviderPermanentError(f"Client error ({status_code}): {e}") from e
146
+ else:
147
+ raise ProviderPermanentError(f"HTTP error: {e}") from e
148
+ except (ProviderPermanentError, ProviderTransientError, ProviderRateLimitError):
149
+ raise
150
+ except Exception as e:
151
+ raise ProviderPermanentError(f"Unexpected error calling endpoint: {e}") from e
152
+
153
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
154
+ """
155
+ Run inference and return raw results.
156
+
157
+ Args:
158
+ pipeline: Pipeline specification
159
+ request: Inference request
160
+
161
+ Returns:
162
+ Raw inference result
163
+
164
+ Raises:
165
+ ProviderError: For any provider-related failures
166
+ """
167
+ if request.product_type != ProductType.PARSE:
168
+ raise ProviderPermanentError(
169
+ f"DoclingParseProvider only supports PARSE product type, got {request.product_type}"
170
+ )
171
+
172
+ started_at = datetime.now()
173
+
174
+ # Check if file exists
175
+ source_path = Path(request.source_file_path)
176
+ if not source_path.exists():
177
+ raise ProviderPermanentError(f"Source file not found: {source_path}")
178
+
179
+ try:
180
+ # Read PDF bytes
181
+ pdf_bytes = source_path.read_bytes()
182
+
183
+ # Call endpoint
184
+ raw_output = self._call_endpoint(pdf_bytes)
185
+
186
+ completed_at = datetime.now()
187
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
188
+
189
+ return RawInferenceResult(
190
+ request=request,
191
+ pipeline=pipeline,
192
+ pipeline_name=pipeline.pipeline_name,
193
+ product_type=request.product_type,
194
+ raw_output=raw_output,
195
+ started_at=started_at,
196
+ completed_at=completed_at,
197
+ latency_in_ms=latency_ms,
198
+ )
199
+
200
+ except (ProviderPermanentError, ProviderTransientError, ProviderRateLimitError):
201
+ raise
202
+ except Exception as e:
203
+ raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
204
+
205
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
206
+ """
207
+ Normalize raw inference result to produce ParseOutput.
208
+
209
+ Args:
210
+ raw_result: Raw inference result from run_inference()
211
+
212
+ Returns:
213
+ Inference result with ParseOutput
214
+
215
+ Raises:
216
+ ProviderError: For any normalization failures
217
+ """
218
+ if raw_result.product_type != ProductType.PARSE:
219
+ raise ProviderPermanentError(
220
+ f"DoclingParseProvider only supports PARSE product type, got {raw_result.product_type}"
221
+ )
222
+
223
+ # Extract pages from response
224
+ # Response format:
225
+ # {
226
+ # "pages": [{"page": 1, "markdown": "..."}, ...],
227
+ # "markdown": "...",
228
+ # "docling_document": {...},
229
+ # }
230
+ raw_pages = raw_result.raw_output.get("pages", [])
231
+ full_markdown = raw_result.raw_output.get("markdown", "")
232
+ raw_docling_document = raw_result.raw_output.get("docling_document")
233
+
234
+ # Convert to PageIR list (0-indexed)
235
+ pages: list[PageIR] = []
236
+ for page_data in raw_pages:
237
+ # Docling uses 1-indexed pages, we use 0-indexed
238
+ page_number = page_data.get("page", 1)
239
+ page_index = page_number - 1 if page_number > 0 else 0
240
+ markdown = page_data.get("markdown", "")
241
+
242
+ pages.append(PageIR(page_index=page_index, markdown=markdown))
243
+
244
+ # Sort by page index
245
+ pages.sort(key=lambda p: p.page_index)
246
+
247
+ # If we have pages but no full markdown, concatenate
248
+ if pages and not full_markdown:
249
+ full_markdown = "\n\n".join(p.markdown for p in pages)
250
+
251
+ layout_pages: list[ParseLayoutPageIR] = []
252
+ if raw_docling_document is not None:
253
+ try:
254
+ docling_document = DoclingDocument.model_validate(raw_docling_document)
255
+ except Exception as e:
256
+ raise ProviderPermanentError(f"Failed to validate docling_document payload: {e}") from e
257
+
258
+ layout_pages = _build_docling_layout_pages(
259
+ doc=docling_document,
260
+ raw_pages=[page for page in raw_pages if isinstance(page, dict)],
261
+ )
262
+
263
+ output = ParseOutput(
264
+ task_type="parse",
265
+ example_id=raw_result.request.example_id,
266
+ pipeline_name=raw_result.pipeline_name,
267
+ pages=pages,
268
+ layout_pages=layout_pages,
269
+ markdown=full_markdown,
270
+ )
271
+
272
+ return InferenceResult(
273
+ request=raw_result.request,
274
+ pipeline_name=raw_result.pipeline_name,
275
+ product_type=raw_result.product_type,
276
+ raw_output=raw_result.raw_output,
277
+ output=output,
278
+ started_at=raw_result.started_at,
279
+ completed_at=raw_result.completed_at,
280
+ latency_in_ms=raw_result.latency_in_ms,
281
+ )