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,574 @@
1
+ """Provider for dots.ocr parse via Modal OpenAI-compatible API.
2
+
3
+ Supports two prompt modes:
4
+ - ``prompt_parse_markdown``: Returns clean markdown (parse-only, no layout data).
5
+ - ``prompt_layout_all_en_v1_5``: Returns structured JSON with bboxes, categories,
6
+ and text. Markdown is reassembled from the layout elements and ``layout_pages``
7
+ is populated so the same pipeline can be cross-evaluated for layout detection.
8
+ """
9
+
10
+ import base64
11
+ import io
12
+ import os
13
+ import re
14
+ import time
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from openai import OpenAI
20
+ from PIL import Image
21
+ from pydantic import BaseModel, Field
22
+
23
+ from parse_bench.inference.providers.base import (
24
+ Provider,
25
+ ProviderConfigError,
26
+ ProviderPermanentError,
27
+ ProviderTransientError,
28
+ )
29
+ from parse_bench.inference.providers.registry import register_provider
30
+ from parse_bench.schemas.parse_output import (
31
+ LayoutItemIR,
32
+ LayoutSegmentIR,
33
+ PageIR,
34
+ ParseLayoutPageIR,
35
+ ParseOutput,
36
+ )
37
+ from parse_bench.schemas.pipeline import PipelineSpec
38
+ from parse_bench.schemas.pipeline_io import (
39
+ InferenceRequest,
40
+ InferenceResult,
41
+ RawInferenceResult,
42
+ )
43
+ from parse_bench.schemas.product import ProductType
44
+
45
+ # Default model name served by the Modal vLLM deployment
46
+ SERVED_MODEL_NAME = "dots-ocr-1.5"
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Prompt definitions
50
+ # ---------------------------------------------------------------------------
51
+
52
+ # Markdown-oriented prompt (no layout data)
53
+ PROMPT_PARSE_MARKDOWN = (
54
+ "Parse this document image and output its content as clean markdown.\n"
55
+ "- Preserve document structure (headings, paragraphs, lists, tables)\n"
56
+ "- Convert tables to HTML format (<table>, <tr>, <th>, <td>) "
57
+ "with colspan/rowspan for merged cells\n"
58
+ "- Format formulas as LaTeX\n"
59
+ "- Describe images/figures briefly in square brackets "
60
+ "like [Figure: description]\n"
61
+ "- Maintain reading order\n"
62
+ "- Output the original text with no translation\n"
63
+ "- Do not add commentary - only output the parsed content\n"
64
+ )
65
+
66
+ # dots.ocr 1.5 layout+text prompt (Core11 categories, structured JSON output)
67
+ PROMPT_LAYOUT_ALL_EN_V1_5 = (
68
+ "Please output the layout information from the PDF image, "
69
+ "including each layout element's bbox, its category, and the "
70
+ "corresponding text content within the bbox.\n"
71
+ "\n"
72
+ "1. Bbox format: [x1, y1, x2, y2]\n"
73
+ "\n"
74
+ "2. Layout Categories: The possible categories are "
75
+ "['Caption', 'Footnote', 'Formula', 'List-item', 'Page-footer', "
76
+ "'Page-header', 'Picture', 'Section-header', 'Table', 'Text', 'Title'].\n"
77
+ "\n"
78
+ "3. Text Extraction & Formatting Rules:\n"
79
+ " - Picture: If the picture is a chart or graph, extract all data points "
80
+ "and format as an HTML table with flat combined column headers "
81
+ "(e.g., 'Revenue 2023' not nested header rows). Include axis labels "
82
+ "as column/row headers. For non-chart pictures, the text field should "
83
+ "be omitted.\n"
84
+ " - Formula: Format its text as LaTeX.\n"
85
+ " - Table: Format its text as HTML.\n"
86
+ " - All Others (Text, Title, etc.): Format their text as Markdown.\n"
87
+ "\n"
88
+ "4. Constraints:\n"
89
+ " - The output text must be the original text from the image, "
90
+ "with no translation.\n"
91
+ " - All layout elements must be sorted according to human reading order.\n"
92
+ "\n"
93
+ "5. Final Output: The entire output must be a single JSON object.\n"
94
+ )
95
+
96
+ PROMPT_CONFIGS: dict[str, str] = {
97
+ "prompt_parse_markdown": PROMPT_PARSE_MARKDOWN,
98
+ "prompt_layout_all_en_v1_5": PROMPT_LAYOUT_ALL_EN_V1_5,
99
+ }
100
+
101
+ # Prompt modes that return structured JSON with layout elements
102
+ _LAYOUT_PROMPT_MODES = {"prompt_layout_all_en_v1_5"}
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # dots.ocr layout response schema
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ class DotsOcrLayoutItem(BaseModel):
111
+ """Single layout element returned by the dots.ocr layout prompt."""
112
+
113
+ bbox: list[float] = Field(..., min_length=4, max_length=4)
114
+ category: str
115
+ text: str = ""
116
+
117
+
118
+ @register_provider("dots_ocr_parse")
119
+ class DotsOcrParseProvider(Provider):
120
+ """
121
+ Unified parse provider for dots.ocr deployed on Modal.
122
+
123
+ When ``prompt_mode`` is a layout prompt (e.g. ``prompt_layout_all_en_v1_5``),
124
+ the model returns structured JSON with bounding boxes, categories, and text.
125
+ The provider reassembles markdown from the layout elements and populates
126
+ ``ParseOutput.layout_pages`` so the same pipeline can be cross-evaluated
127
+ against layout detection datasets (following the LlamaParse pattern).
128
+
129
+ When ``prompt_mode`` is ``prompt_parse_markdown`` (default), the model
130
+ returns clean markdown and no layout data is produced.
131
+
132
+ Configuration options:
133
+ - endpoint_url (str, required): Modal server URL (or DOTS_OCR_ENDPOINT_URL env var)
134
+ - model (str, default: "dots-ocr-1.5"): Served model name
135
+ - prompt_mode (str, default: "prompt_parse_markdown"): Prompt selection
136
+ - timeout (int, default: 180): Request timeout in seconds
137
+ - max_tokens (int, default: 32768): Max tokens per response
138
+ - dpi (int, default: 150): DPI for PDF to image conversion
139
+ - temperature (float, default: 0.1): Sampling temperature
140
+ - top_p (float, default: 0.9): Top-p sampling
141
+ - prompt_override (str, optional): Custom prompt text (overrides prompt_mode)
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ provider_name: str,
147
+ base_config: dict[str, Any] | None = None,
148
+ ) -> None:
149
+ super().__init__(provider_name, base_config)
150
+
151
+ endpoint_url = self.base_config.get("endpoint_url") or os.getenv("DOTS_OCR_ENDPOINT_URL")
152
+ if not endpoint_url:
153
+ raise ProviderConfigError(
154
+ "endpoint_url is required for dots_ocr_parse provider. "
155
+ "Set DOTS_OCR_ENDPOINT_URL or pass endpoint_url in config."
156
+ )
157
+
158
+ self._client = OpenAI(
159
+ base_url=endpoint_url,
160
+ api_key=os.getenv("DOTS_OCR_API_KEY", "not-needed"),
161
+ )
162
+
163
+ self._model = self.base_config.get("model", SERVED_MODEL_NAME)
164
+ self._timeout = self.base_config.get("timeout", 180)
165
+ self._max_tokens = self.base_config.get("max_tokens", 16384)
166
+ self._dpi = self.base_config.get("dpi", 150)
167
+ self._temperature = self.base_config.get("temperature", 0.1)
168
+ self._top_p = self.base_config.get("top_p", 0.9)
169
+
170
+ self._prompt_mode = self.base_config.get("prompt_mode", "prompt_parse_markdown")
171
+ prompt_override = self.base_config.get("prompt_override")
172
+ if prompt_override:
173
+ self._prompt = prompt_override
174
+ else:
175
+ prompt = PROMPT_CONFIGS.get(self._prompt_mode)
176
+ if not prompt:
177
+ raise ProviderConfigError(
178
+ f"Unknown prompt_mode '{self._prompt_mode}'. Available: {sorted(PROMPT_CONFIGS.keys())}"
179
+ )
180
+ self._prompt = prompt
181
+
182
+ self._is_layout_mode = self._prompt_mode in _LAYOUT_PROMPT_MODES
183
+
184
+ # ------------------------------------------------------------------
185
+ # Image helpers
186
+ # ------------------------------------------------------------------
187
+
188
+ def _image_to_base64(self, image: Image.Image) -> str:
189
+ buffer = io.BytesIO()
190
+ image.save(buffer, format="PNG")
191
+ buffer.seek(0)
192
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
193
+
194
+ def _pdf_to_images(self, pdf_path: str) -> list[Image.Image]:
195
+ try:
196
+ from pdf2image import convert_from_path
197
+ except ImportError as e:
198
+ raise ProviderConfigError("pdf2image package not installed. Run: pip install pdf2image") from e
199
+ try:
200
+ return convert_from_path(pdf_path, dpi=self._dpi)
201
+ except Exception as e:
202
+ raise ProviderPermanentError(f"Failed to convert PDF to images: {e}") from e
203
+
204
+ # ------------------------------------------------------------------
205
+ # API call
206
+ # ------------------------------------------------------------------
207
+
208
+ def _call_endpoint(self, image: Image.Image) -> str:
209
+ """Call dots.ocr via OpenAI-compatible API and return raw response text."""
210
+ img_base64 = self._image_to_base64(image)
211
+ try:
212
+ response = self._client.chat.completions.create(
213
+ model=self._model,
214
+ messages=[
215
+ {
216
+ "role": "user",
217
+ "content": [
218
+ {"type": "text", "text": self._prompt},
219
+ {
220
+ "type": "image_url",
221
+ "image_url": {"url": f"data:image/png;base64,{img_base64}"},
222
+ },
223
+ ],
224
+ },
225
+ ],
226
+ max_tokens=self._max_tokens,
227
+ temperature=self._temperature,
228
+ top_p=self._top_p,
229
+ )
230
+ except Exception as e:
231
+ error_msg = str(e).lower()
232
+ if "timeout" in error_msg or "connection" in error_msg:
233
+ raise ProviderTransientError(f"API call failed: {e}") from e
234
+ raise ProviderPermanentError(f"API call failed: {e}") from e
235
+
236
+ content = response.choices[0].message.content
237
+ if not content:
238
+ raise ProviderPermanentError("Empty response from model")
239
+ return content
240
+
241
+ # ------------------------------------------------------------------
242
+ # HTML sanitization
243
+ # ------------------------------------------------------------------
244
+
245
+ @staticmethod
246
+ def _sanitize_html_attributes(text: str) -> str:
247
+ """Quote unquoted HTML attributes so tables are valid XML."""
248
+
249
+ def _quote_attrs(match: re.Match) -> str:
250
+ tag_text = match.group(0)
251
+ tag_text = re.sub(
252
+ r'(\w+)=([^\s"\'<>=]+)',
253
+ r'\1="\2"',
254
+ tag_text,
255
+ )
256
+ return tag_text
257
+
258
+ return re.sub(r"<[^>]+>", _quote_attrs, text)
259
+
260
+ # ------------------------------------------------------------------
261
+ # Layout JSON parsing
262
+ # ------------------------------------------------------------------
263
+
264
+ @staticmethod
265
+ def _parse_layout_items(content: str) -> list[DotsOcrLayoutItem]:
266
+ """Parse dots.ocr layout response into typed items.
267
+
268
+ The model is fine-tuned to return a JSON array of
269
+ ``{bbox, category, text}`` objects. We try ``json.loads``
270
+ first, then fall back to extracting a JSON array from
271
+ markdown fences or raw brackets.
272
+ """
273
+ candidates: list[str] = [content]
274
+
275
+ # Fallback: extract from ```json ... ``` fences
276
+ fence = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content)
277
+ if fence:
278
+ candidates.append(fence.group(1))
279
+
280
+ # Fallback: extract outermost [...] bracket
281
+ bracket = re.search(r"\[[\s\S]*\]", content)
282
+ if bracket:
283
+ candidates.append(bracket.group(0))
284
+
285
+ from pydantic import TypeAdapter
286
+
287
+ adapter = TypeAdapter(list[DotsOcrLayoutItem])
288
+ for candidate in candidates:
289
+ try:
290
+ return adapter.validate_json(candidate)
291
+ except Exception:
292
+ continue
293
+
294
+ raise ProviderPermanentError(f"Could not parse layout items from response: {content[:500]}")
295
+
296
+ # ------------------------------------------------------------------
297
+ # Per-page inference
298
+ # ------------------------------------------------------------------
299
+
300
+ def _run_inference_pages(self, source_path: Path) -> dict[str, Any]:
301
+ """Convert source file to images and run inference on each page."""
302
+ if source_path.suffix.lower() == ".pdf":
303
+ images = self._pdf_to_images(str(source_path))
304
+ else:
305
+ images = [Image.open(source_path)]
306
+
307
+ pages = []
308
+ for page_index, image in enumerate(images):
309
+ if image.mode not in ("RGB", "RGBA"):
310
+ image = image.convert("RGB")
311
+
312
+ raw_text = self._call_endpoint(image)
313
+
314
+ page_data: dict[str, Any] = {
315
+ "page_index": page_index,
316
+ "width": image.width,
317
+ "height": image.height,
318
+ "raw_response": raw_text,
319
+ }
320
+
321
+ if self._is_layout_mode:
322
+ # Parse structured JSON → typed layout items + reassemble markdown
323
+ try:
324
+ layout_items = self._parse_layout_items(raw_text)
325
+ except ProviderPermanentError:
326
+ layout_items = []
327
+
328
+ page_data["layout_items"] = [item.model_dump() for item in layout_items]
329
+ page_data["markdown"] = _reassemble_markdown(layout_items)
330
+ else:
331
+ page_data["markdown"] = raw_text
332
+ page_data["layout_items"] = []
333
+
334
+ pages.append(page_data)
335
+
336
+ return {
337
+ "pages": pages,
338
+ "num_pages": len(images),
339
+ "model": self._model,
340
+ "prompt_mode": self._prompt_mode,
341
+ "config": {
342
+ "dpi": self._dpi,
343
+ "max_tokens": self._max_tokens,
344
+ "timeout": self._timeout,
345
+ },
346
+ }
347
+
348
+ # ------------------------------------------------------------------
349
+ # run_inference
350
+ # ------------------------------------------------------------------
351
+
352
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
353
+ if request.product_type != ProductType.PARSE:
354
+ raise ProviderPermanentError(
355
+ f"DotsOcrParseProvider only supports PARSE product type, got {request.product_type}"
356
+ )
357
+
358
+ source_path = Path(request.source_file_path)
359
+ if not source_path.exists():
360
+ raise ProviderPermanentError(f"Source file not found: {source_path}")
361
+
362
+ supported_extensions = {".pdf", ".png", ".jpg", ".jpeg", ".webp", ".tiff", ".bmp"}
363
+ if source_path.suffix.lower() not in supported_extensions:
364
+ raise ProviderPermanentError(
365
+ f"DotsOcrParseProvider supports {supported_extensions}, got {source_path.suffix}"
366
+ )
367
+
368
+ started_at = datetime.now()
369
+ max_retries = 3
370
+ last_error: Exception | None = None
371
+
372
+ for attempt in range(max_retries):
373
+ try:
374
+ raw_output = self._run_inference_pages(source_path)
375
+
376
+ completed_at = datetime.now()
377
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
378
+
379
+ return RawInferenceResult(
380
+ request=request,
381
+ pipeline=pipeline,
382
+ pipeline_name=pipeline.pipeline_name,
383
+ product_type=request.product_type,
384
+ raw_output=raw_output,
385
+ started_at=started_at,
386
+ completed_at=completed_at,
387
+ latency_in_ms=latency_ms,
388
+ )
389
+
390
+ except ProviderTransientError as e:
391
+ last_error = e
392
+ if attempt < max_retries - 1:
393
+ delay = 15 * (2**attempt)
394
+ print(
395
+ f"[dots.ocr] Transient error on {request.example_id}: {e}. "
396
+ f"Retrying in {delay}s (attempt {attempt + 1}/{max_retries})..."
397
+ )
398
+ time.sleep(delay)
399
+ continue
400
+
401
+ except (ProviderPermanentError, ProviderConfigError) as e:
402
+ last_error = e
403
+ break
404
+
405
+ except Exception as e:
406
+ last_error = e
407
+ break
408
+
409
+ completed_at = datetime.now()
410
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
411
+
412
+ error_msg = str(last_error)
413
+ if isinstance(last_error, TimeoutError):
414
+ error_msg = f"Request timed out after {self._timeout} seconds"
415
+
416
+ return RawInferenceResult(
417
+ request=request,
418
+ pipeline=pipeline,
419
+ pipeline_name=pipeline.pipeline_name,
420
+ product_type=request.product_type,
421
+ raw_output={
422
+ "pages": [],
423
+ "_error": error_msg,
424
+ "_error_type": type(last_error).__name__ if last_error else "Unknown",
425
+ "model": self._model,
426
+ "config": {
427
+ "dpi": self._dpi,
428
+ "max_tokens": self._max_tokens,
429
+ "timeout": self._timeout,
430
+ },
431
+ },
432
+ started_at=started_at,
433
+ completed_at=completed_at,
434
+ latency_in_ms=latency_ms,
435
+ )
436
+
437
+ # ------------------------------------------------------------------
438
+ # normalize
439
+ # ------------------------------------------------------------------
440
+
441
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
442
+ if raw_result.product_type != ProductType.PARSE:
443
+ raise ProviderPermanentError(
444
+ f"DotsOcrParseProvider only supports PARSE product type, got {raw_result.product_type}"
445
+ )
446
+
447
+ pages: list[PageIR] = []
448
+ layout_pages: list[ParseLayoutPageIR] = []
449
+ page_markdowns: list[str] = []
450
+
451
+ for page_data in raw_result.raw_output.get("pages", []):
452
+ page_index = page_data.get("page_index", 0)
453
+ markdown = page_data.get("markdown", "")
454
+ img_width = page_data.get("width", 0)
455
+ img_height = page_data.get("height", 0)
456
+
457
+ if markdown:
458
+ markdown = self._sanitize_html_attributes(markdown)
459
+
460
+ pages.append(PageIR(page_index=page_index, markdown=markdown))
461
+ page_markdowns.append(markdown)
462
+
463
+ # Build layout_pages from structured layout items (if available)
464
+ layout_items = page_data.get("layout_items", [])
465
+ if layout_items and img_width > 0 and img_height > 0:
466
+ layout_page = _build_layout_page(
467
+ layout_items=layout_items,
468
+ page_number=page_index + 1,
469
+ img_width=img_width,
470
+ img_height=img_height,
471
+ page_markdown=markdown,
472
+ )
473
+ layout_pages.append(layout_page)
474
+
475
+ pages.sort(key=lambda p: p.page_index)
476
+ full_markdown = "\n\n".join(page_markdowns)
477
+
478
+ output = ParseOutput(
479
+ task_type="parse",
480
+ example_id=raw_result.request.example_id,
481
+ pipeline_name=raw_result.pipeline_name,
482
+ pages=pages,
483
+ layout_pages=layout_pages,
484
+ markdown=full_markdown,
485
+ )
486
+
487
+ return InferenceResult(
488
+ request=raw_result.request,
489
+ pipeline_name=raw_result.pipeline_name,
490
+ product_type=raw_result.product_type,
491
+ raw_output=raw_result.raw_output,
492
+ output=output,
493
+ started_at=raw_result.started_at,
494
+ completed_at=raw_result.completed_at,
495
+ latency_in_ms=raw_result.latency_in_ms,
496
+ )
497
+
498
+
499
+ # ======================================================================
500
+ # Module-level helpers
501
+ # ======================================================================
502
+
503
+
504
+ def _reassemble_markdown(layout_items: list[DotsOcrLayoutItem]) -> str:
505
+ """Reassemble page markdown from layout element text fields."""
506
+ parts: list[str] = []
507
+ for item in layout_items:
508
+ label = item.category.strip().lower()
509
+ if not item.text:
510
+ continue
511
+
512
+ if label in ("title", "section-header"):
513
+ parts.append(f"## {item.text}")
514
+ elif label == "table":
515
+ parts.append(item.text) # Already HTML
516
+ elif label == "formula":
517
+ parts.append(f"$${item.text}$$")
518
+ else:
519
+ parts.append(item.text)
520
+
521
+ return "\n\n".join(parts)
522
+
523
+
524
+ def _build_layout_page(
525
+ *,
526
+ layout_items: list[dict[str, Any]],
527
+ page_number: int,
528
+ img_width: int,
529
+ img_height: int,
530
+ page_markdown: str,
531
+ ) -> ParseLayoutPageIR:
532
+ """Convert dots.ocr layout items into a ParseLayoutPageIR for cross-eval."""
533
+ from pydantic import TypeAdapter
534
+
535
+ adapter = TypeAdapter(list[DotsOcrLayoutItem])
536
+ typed_items = adapter.validate_python(layout_items)
537
+
538
+ items: list[LayoutItemIR] = []
539
+ for li in typed_items:
540
+ x1, y1, x2, y2 = li.bbox
541
+
542
+ seg = LayoutSegmentIR(
543
+ x=x1 / img_width,
544
+ y=y1 / img_height,
545
+ w=(x2 - x1) / img_width,
546
+ h=(y2 - y1) / img_height,
547
+ confidence=1.0,
548
+ label=li.category,
549
+ )
550
+
551
+ norm_label = li.category.strip().lower()
552
+ if norm_label == "table":
553
+ item_type = "table"
554
+ elif norm_label == "picture":
555
+ item_type = "image"
556
+ else:
557
+ item_type = "text"
558
+
559
+ items.append(
560
+ LayoutItemIR(
561
+ type=item_type,
562
+ value=li.text,
563
+ bbox=seg,
564
+ layout_segments=[seg],
565
+ )
566
+ )
567
+
568
+ return ParseLayoutPageIR(
569
+ page_number=page_number,
570
+ width=float(img_width),
571
+ height=float(img_height),
572
+ md=page_markdown,
573
+ items=items,
574
+ )