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,724 @@
1
+ """Provider for Databricks ``ai_parse_document`` SQL function.
2
+
3
+ ``ai_parse_document`` is a Databricks built-in SQL function. It has no
4
+ dedicated REST endpoint, so we invoke it via the Statement Execution API
5
+ on a SQL Warehouse. The input byte argument must reference a Unity Catalog
6
+ Volume (the ``BINARY`` parameter type is not supported by the SQL
7
+ parameters wire format).
8
+
9
+ Operating modes
10
+ ---------------
11
+ ``batch_size = 1`` (default): one SQL statement per request::
12
+
13
+ PUT /api/2.0/fs/files/<volume>/<uuid>.pdf
14
+ POST /api/2.0/sql/statements/ → SELECT ai_parse_document(content)
15
+ FROM READ_FILES('<volume>/<uuid>.pdf', format => 'binaryFile')
16
+ poll until terminal
17
+ DELETE /api/2.0/fs/files/<volume>/<uuid>.pdf
18
+
19
+ ``batch_size > 1``: coalesce up to K concurrent requests into a single
20
+ statement::
21
+
22
+ PUT /api/2.0/fs/directories/<volume>/batch-<uuid>
23
+ PUT /api/2.0/fs/files/<volume>/batch-<uuid>/<i>.pdf (xK)
24
+ POST /api/2.0/sql/statements/ → SELECT path, ai_parse_document(content)
25
+ FROM READ_FILES('<volume>/batch-<uuid>', format => 'binaryFile')
26
+ poll, follow next_chunk_internal_link if needed, demux by path
27
+ DELETE files + DELETE directory
28
+
29
+ Batching amortizes SQL/warehouse warm-up overhead. ``ai_parse_document``
30
+ itself is billed per-page summed across the batch, so model DBUs do not
31
+ change — only orchestration cost drops.
32
+
33
+ The returned VARIANT is a JSON object shaped like::
34
+
35
+ {
36
+ "document": {
37
+ "pages": [{"id": int, "image_uri": str}],
38
+ "elements": [
39
+ {"id": int, "type": str, "content": str,
40
+ "confidence": float, "bbox": [{"coord": [...], "page_id": int}],
41
+ "description": str}
42
+ ]
43
+ },
44
+ "error_status": [...],
45
+ "metadata": {...}
46
+ }
47
+
48
+ Element ``type`` is one of: text, table, figure, title, caption,
49
+ section_header, page_header, page_footer, page_number, footnote.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import concurrent.futures
55
+ import json
56
+ import os
57
+ import queue
58
+ import threading
59
+ import time
60
+ import uuid
61
+ from datetime import datetime
62
+ from pathlib import Path
63
+ from typing import Any
64
+
65
+ import requests
66
+
67
+ from parse_bench.inference.providers.base import (
68
+ Provider,
69
+ ProviderConfigError,
70
+ ProviderPermanentError,
71
+ ProviderTransientError,
72
+ )
73
+ from parse_bench.inference.providers.registry import register_provider
74
+ from parse_bench.schemas.parse_output import (
75
+ LayoutItemIR,
76
+ LayoutSegmentIR,
77
+ ParseLayoutPageIR,
78
+ ParseOutput,
79
+ )
80
+ from parse_bench.schemas.pipeline import PipelineSpec
81
+ from parse_bench.schemas.pipeline_io import (
82
+ InferenceRequest,
83
+ InferenceResult,
84
+ RawInferenceResult,
85
+ )
86
+ from parse_bench.schemas.product import ProductType
87
+
88
+ # ai_parse_document element type -> Canonical17 label
89
+ DATABRICKS_LABEL_MAP: dict[str, str] = {
90
+ "title": "Title",
91
+ "section_header": "Section-header",
92
+ "text": "Text",
93
+ "table": "Table",
94
+ "figure": "Picture",
95
+ "caption": "Caption",
96
+ "page_header": "Page-header",
97
+ "page_footer": "Page-footer",
98
+ "page_number": "Page-footer",
99
+ "footnote": "Footnote",
100
+ }
101
+
102
+ # ai_parse_document returns element bboxes in absolute pixel coordinates of
103
+ # the page it rendered internally. For PDFs that render happens at 200 DPI
104
+ # (v2 default), so true page dims in that space are points * 200/72; image
105
+ # inputs keep their native pixel dims. Normalizing by anything else (e.g. the
106
+ # max element extent on the page) shifts and stretches every box.
107
+ AI_PARSE_RENDER_DPI = 200.0
108
+
109
+ # Fallback page dimension when the source file can't be read at normalize
110
+ # time (bboxes then stay normalized by element extent — degraded but usable).
111
+ _VIRTUAL_PAGE_DIM = 1000.0
112
+
113
+ _IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp"}
114
+
115
+ _TERMINAL_STATES = {"SUCCEEDED", "FAILED", "CANCELED", "CLOSED"}
116
+ _TRANSIENT_HTTP = {408, 429, 500, 502, 503, 504}
117
+
118
+ _QueueItem = tuple[InferenceRequest, PipelineSpec, "concurrent.futures.Future[RawInferenceResult]"]
119
+
120
+
121
+ @register_provider("databricks_ai_parse")
122
+ class DatabricksAiParseProvider(Provider):
123
+ """Provider for Databricks ``ai_parse_document``.
124
+
125
+ Config:
126
+ - host (str, required): Workspace host, e.g.
127
+ ``adb-xxx.azuredatabricks.net``. Reads ``DATABRICKS_HOST`` if unset.
128
+ - token (str, required): PAT / OAuth bearer token. Reads
129
+ ``DATABRICKS_TOKEN`` if unset.
130
+ - warehouse_id (str, required): SQL Warehouse to run the statement
131
+ on. Reads ``DATABRICKS_SQL_WAREHOUSE_ID`` if unset.
132
+ - volume_path (str, required): UC Volume prefix used as a staging
133
+ area, e.g. ``/Volumes/main/default/llamabench``. Reads
134
+ ``DATABRICKS_AI_PARSE_VOLUME`` if unset.
135
+ - version (str, default "2.0"): ai_parse_document schema version.
136
+ - description_element_types (str, default ""): pass-through for the
137
+ ``descriptionElementTypes`` option (``""``, ``"figure"``, ``"*"``).
138
+ - poll_interval (float, default 2.0): seconds between polls.
139
+ - timeout (int, default 900): total wait budget in seconds for the
140
+ SQL statement.
141
+ - batch_size (int, default 1): number of requests to coalesce into
142
+ a single SQL statement. ``1`` = per-file mode.
143
+ - batch_wait_seconds (float, default 10): when batch_size > 1, the
144
+ debounce window — once the first request arrives, wait at most
145
+ this long for the batch to fill before flushing.
146
+ - per_request_timeout (int, default 1800): max seconds a single
147
+ ``run_inference`` call will wait for its batch to complete.
148
+ Only used when batch_size > 1.
149
+ """
150
+
151
+ def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
152
+ super().__init__(provider_name, base_config)
153
+
154
+ host = self.base_config.get("host") or os.getenv("DATABRICKS_HOST")
155
+ token = self.base_config.get("token") or os.getenv("DATABRICKS_TOKEN")
156
+ warehouse_id = self.base_config.get("warehouse_id") or os.getenv("DATABRICKS_SQL_WAREHOUSE_ID")
157
+ volume_path = self.base_config.get("volume_path") or os.getenv("DATABRICKS_AI_PARSE_VOLUME")
158
+
159
+ if not host:
160
+ raise ProviderConfigError(
161
+ "Databricks host is required. Set DATABRICKS_HOST env var or pass 'host' in base_config."
162
+ )
163
+ if not token:
164
+ raise ProviderConfigError(
165
+ "Databricks token is required. Set DATABRICKS_TOKEN env var or pass 'token' in base_config."
166
+ )
167
+ if not warehouse_id:
168
+ raise ProviderConfigError(
169
+ "Databricks warehouse_id is required. "
170
+ "Set DATABRICKS_SQL_WAREHOUSE_ID env var or pass 'warehouse_id' in base_config."
171
+ )
172
+ if not volume_path:
173
+ raise ProviderConfigError(
174
+ "Databricks volume_path is required. "
175
+ "Set DATABRICKS_AI_PARSE_VOLUME env var (e.g. '/Volumes/main/default/llamabench') "
176
+ "or pass 'volume_path' in base_config."
177
+ )
178
+ if not volume_path.startswith("/Volumes/"):
179
+ raise ProviderConfigError(f"volume_path must start with '/Volumes/' (got {volume_path!r}).")
180
+
181
+ self._base_url = f"https://{host.rstrip('/').removeprefix('https://').removeprefix('http://')}"
182
+ self._auth_headers = {"Authorization": f"Bearer {token}"}
183
+ self._warehouse_id = warehouse_id
184
+ self._volume_base = volume_path.rstrip("/")
185
+ self._version = str(self.base_config.get("version", "2.0"))
186
+ self._description_element_types = self.base_config.get("description_element_types", "")
187
+ self._poll_interval = float(self.base_config.get("poll_interval", 2.0))
188
+ self._timeout = int(self.base_config.get("timeout", 900))
189
+
190
+ batch_size = int(self.base_config.get("batch_size", 1))
191
+ self._batch_size = max(1, batch_size)
192
+ self._batch_wait_s = float(self.base_config.get("batch_wait_seconds", 10.0))
193
+ self._per_request_timeout = int(self.base_config.get("per_request_timeout", 1800))
194
+
195
+ # Batch worker is lazy — only spawned when batch_size > 1 and the
196
+ # first request arrives.
197
+ self._queue: queue.Queue[_QueueItem] = queue.Queue()
198
+ self._worker: threading.Thread | None = None
199
+ self._worker_lock = threading.Lock()
200
+
201
+ # ------------------------------------------------------------------ HTTP
202
+
203
+ def _upload_file(self, local_path: Path, remote_path: str) -> None:
204
+ url = f"{self._base_url}/api/2.0/fs/files{remote_path}"
205
+ with open(local_path, "rb") as fh:
206
+ resp = requests.put(
207
+ url,
208
+ params={"overwrite": "true"},
209
+ headers={**self._auth_headers, "Content-Type": "application/octet-stream"},
210
+ data=fh,
211
+ timeout=self._timeout,
212
+ )
213
+ self._raise_for_http(resp, f"upload {remote_path}")
214
+
215
+ def _delete_file(self, remote_path: str) -> None:
216
+ url = f"{self._base_url}/api/2.0/fs/files{remote_path}"
217
+ try:
218
+ requests.delete(url, headers=self._auth_headers, timeout=60)
219
+ except Exception:
220
+ # Cleanup is best-effort; never mask a parse failure with a delete failure.
221
+ pass
222
+
223
+ def _create_directory(self, remote_dir: str) -> None:
224
+ url = f"{self._base_url}/api/2.0/fs/directories{remote_dir}"
225
+ resp = requests.put(url, headers=self._auth_headers, timeout=60)
226
+ self._raise_for_http(resp, f"create directory {remote_dir}")
227
+
228
+ def _delete_directory(self, remote_dir: str) -> None:
229
+ url = f"{self._base_url}/api/2.0/fs/directories{remote_dir}"
230
+ try:
231
+ requests.delete(url, headers=self._auth_headers, timeout=60)
232
+ except Exception:
233
+ pass
234
+
235
+ @staticmethod
236
+ def _raise_for_http(resp: requests.Response, context: str) -> None:
237
+ if resp.ok:
238
+ return
239
+ text = resp.text[:500]
240
+ if resp.status_code in _TRANSIENT_HTTP:
241
+ raise ProviderTransientError(f"HTTP {resp.status_code} during {context}: {text}")
242
+ raise ProviderPermanentError(f"HTTP {resp.status_code} during {context}: {text}")
243
+
244
+ # ------------------------------------------------------------------ SQL
245
+
246
+ def _build_statement(self, source_ref: str, *, include_path: bool) -> str:
247
+ options = [f"'version', '{self._version}'"]
248
+ if self._description_element_types:
249
+ safe = self._description_element_types.replace("'", "''")
250
+ options.append(f"'descriptionElementTypes', '{safe}'")
251
+ option_map = ", ".join(options)
252
+ select_cols = "path, " if include_path else ""
253
+ return (
254
+ f"SELECT {select_cols}ai_parse_document(content, map({option_map})) AS result "
255
+ f"FROM READ_FILES('{source_ref}', format => 'binaryFile')"
256
+ )
257
+
258
+ def _execute_statement(self, statement: str) -> dict[str, Any]:
259
+ payload = {
260
+ "warehouse_id": self._warehouse_id,
261
+ "statement": statement,
262
+ "wait_timeout": "50s",
263
+ "on_wait_timeout": "CONTINUE",
264
+ "disposition": "INLINE",
265
+ "format": "JSON_ARRAY",
266
+ }
267
+ url = f"{self._base_url}/api/2.0/sql/statements/"
268
+ resp = requests.post(
269
+ url,
270
+ headers={**self._auth_headers, "Content-Type": "application/json"},
271
+ json=payload,
272
+ timeout=self._timeout,
273
+ )
274
+ self._raise_for_http(resp, "submit statement")
275
+ body = resp.json()
276
+
277
+ deadline = time.time() + self._timeout
278
+ while body["status"]["state"] not in _TERMINAL_STATES:
279
+ if time.time() > deadline:
280
+ raise ProviderTransientError(
281
+ f"Databricks statement {body.get('statement_id')!r} did not finish within {self._timeout}s."
282
+ )
283
+ time.sleep(self._poll_interval)
284
+ poll = requests.get(
285
+ f"{self._base_url}/api/2.0/sql/statements/{body['statement_id']}",
286
+ headers=self._auth_headers,
287
+ timeout=60,
288
+ )
289
+ self._raise_for_http(poll, "poll statement")
290
+ body = poll.json()
291
+
292
+ state = body["status"]["state"]
293
+ if state != "SUCCEEDED":
294
+ err = body["status"].get("error") or {}
295
+ msg = err.get("message") or state
296
+ raise ProviderPermanentError(f"Databricks statement ended in {state}: {msg}")
297
+
298
+ return self._collect_all_result_chunks(body)
299
+
300
+ def _collect_all_result_chunks(self, body: dict[str, Any]) -> dict[str, Any]:
301
+ """Follow ``next_chunk_internal_link`` so callers see one unified
302
+ ``result.data_array``. INLINE responses are capped at 25 MiB per
303
+ chunk."""
304
+ result = body.get("result") or {}
305
+ all_rows: list[list[Any]] = list(result.get("data_array") or [])
306
+ next_link = result.get("next_chunk_internal_link")
307
+ while next_link:
308
+ r = requests.get(
309
+ f"{self._base_url}{next_link}",
310
+ headers=self._auth_headers,
311
+ timeout=self._timeout,
312
+ )
313
+ self._raise_for_http(r, "fetch result chunk")
314
+ chunk = r.json()
315
+ all_rows.extend(chunk.get("data_array") or [])
316
+ next_link = chunk.get("next_chunk_internal_link")
317
+ body.setdefault("result", {})["data_array"] = all_rows
318
+ return body
319
+
320
+ @staticmethod
321
+ def _coerce_variant(cell: Any) -> dict[str, Any]:
322
+ if cell is None:
323
+ raise ProviderPermanentError("Databricks ai_parse_document returned NULL.")
324
+ if isinstance(cell, str):
325
+ try:
326
+ parsed = json.loads(cell)
327
+ except json.JSONDecodeError as e:
328
+ raise ProviderPermanentError(f"Failed to decode VARIANT JSON: {e}") from e
329
+ if not isinstance(parsed, dict):
330
+ raise ProviderPermanentError(f"VARIANT JSON is not an object: {type(parsed).__name__}")
331
+ return parsed
332
+ if isinstance(cell, dict):
333
+ return cell
334
+ raise ProviderPermanentError(f"Unexpected VARIANT cell type: {type(cell).__name__}")
335
+
336
+ @staticmethod
337
+ def _normalize_row_path(row_path: str) -> str:
338
+ """``READ_FILES`` returns full volume URIs. Strip any ``dbfs:``
339
+ prefix that older runtimes add, just in case."""
340
+ if row_path.startswith("dbfs:"):
341
+ return row_path[len("dbfs:") :]
342
+ return row_path
343
+
344
+ # ------------------------------------------------------------------ Inference
345
+
346
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
347
+ if request.product_type != ProductType.PARSE:
348
+ raise ProviderPermanentError(f"DatabricksAiParseProvider only supports PARSE, got {request.product_type}")
349
+ if self._batch_size <= 1:
350
+ return self._run_single(pipeline, request)
351
+ return self._run_batched(pipeline, request)
352
+
353
+ # Per-file mode -------------------------------------------------------
354
+
355
+ def _run_single(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
356
+ source = Path(request.source_file_path)
357
+ if not source.exists():
358
+ raise ProviderPermanentError(f"Source file not found: {source}")
359
+
360
+ remote_name = f"{uuid.uuid4().hex}{source.suffix.lower()}"
361
+ remote_path = f"{self._volume_base}/{remote_name}"
362
+
363
+ started_at = datetime.now()
364
+ try:
365
+ self._upload_file(source, remote_path)
366
+ statement = self._build_statement(remote_path, include_path=False)
367
+ response = self._execute_statement(statement)
368
+ rows = (response.get("result") or {}).get("data_array") or []
369
+ if not rows or not rows[0]:
370
+ raise ProviderPermanentError("Databricks statement returned no rows.")
371
+ variant = self._coerce_variant(rows[0][0])
372
+ finally:
373
+ self._delete_file(remote_path)
374
+
375
+ completed_at = datetime.now()
376
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
377
+
378
+ return RawInferenceResult(
379
+ request=request,
380
+ pipeline=pipeline,
381
+ pipeline_name=pipeline.pipeline_name,
382
+ product_type=request.product_type,
383
+ raw_output={
384
+ "ai_parse_document": variant,
385
+ "statement_id": response.get("statement_id"),
386
+ "_config": self._config_snapshot(),
387
+ },
388
+ started_at=started_at,
389
+ completed_at=completed_at,
390
+ latency_in_ms=latency_ms,
391
+ )
392
+
393
+ # Batch mode ----------------------------------------------------------
394
+
395
+ def _run_batched(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
396
+ self._ensure_worker_started()
397
+ fut: concurrent.futures.Future[RawInferenceResult] = concurrent.futures.Future()
398
+ self._queue.put((request, pipeline, fut))
399
+ return fut.result(timeout=self._per_request_timeout)
400
+
401
+ def _ensure_worker_started(self) -> None:
402
+ if self._worker is not None:
403
+ return
404
+ with self._worker_lock:
405
+ if self._worker is None:
406
+ t = threading.Thread(
407
+ target=self._worker_loop,
408
+ name="databricks-ai-parse-batch",
409
+ daemon=True,
410
+ )
411
+ t.start()
412
+ self._worker = t
413
+
414
+ def _worker_loop(self) -> None:
415
+ while True:
416
+ batch: list[_QueueItem] = [self._queue.get()]
417
+ deadline = time.time() + self._batch_wait_s
418
+ while len(batch) < self._batch_size:
419
+ remaining = deadline - time.time()
420
+ if remaining <= 0:
421
+ break
422
+ try:
423
+ batch.append(self._queue.get(timeout=remaining))
424
+ except queue.Empty:
425
+ break
426
+ try:
427
+ self._process_batch(batch)
428
+ except Exception as exc: # noqa: BLE001 — propagate to awaiting futures
429
+ for _, _, fut in batch:
430
+ if not fut.done():
431
+ fut.set_exception(exc)
432
+
433
+ def _process_batch(self, batch: list[_QueueItem]) -> None:
434
+ started_at = datetime.now()
435
+ batch_id = uuid.uuid4().hex
436
+ batch_dir = f"{self._volume_base}/batch-{batch_id}"
437
+
438
+ self._create_directory(batch_dir)
439
+
440
+ # Key the demux mapping by the full volume path READ_FILES echoes back.
441
+ file_mapping: dict[str, _QueueItem] = {}
442
+ uploaded: list[str] = []
443
+ try:
444
+ for idx, item in enumerate(batch):
445
+ req, _pipe, fut = item
446
+ src = Path(req.source_file_path)
447
+ if not src.exists():
448
+ if not fut.done():
449
+ fut.set_exception(ProviderPermanentError(f"Source file not found: {src}"))
450
+ continue
451
+ remote_name = f"{idx:04d}-{uuid.uuid4().hex}{src.suffix.lower()}"
452
+ remote_path = f"{batch_dir}/{remote_name}"
453
+ try:
454
+ self._upload_file(src, remote_path)
455
+ except Exception as exc: # noqa: BLE001
456
+ if not fut.done():
457
+ fut.set_exception(exc)
458
+ continue
459
+ uploaded.append(remote_path)
460
+ file_mapping[remote_path] = item
461
+
462
+ if not file_mapping:
463
+ return
464
+
465
+ statement = self._build_statement(batch_dir, include_path=True)
466
+ response = self._execute_statement(statement)
467
+ completed_at = datetime.now()
468
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
469
+
470
+ rows = (response.get("result") or {}).get("data_array") or []
471
+ fulfilled: set[str] = set()
472
+ for row in rows:
473
+ if not row or len(row) < 2:
474
+ continue
475
+ row_path = self._normalize_row_path(row[0])
476
+ entry = file_mapping.get(row_path)
477
+ if entry is None or entry[2].done():
478
+ fulfilled.add(row_path)
479
+ continue
480
+ req_i, pipe_i, fut = entry
481
+ try:
482
+ variant = self._coerce_variant(row[1])
483
+ except Exception as exc: # noqa: BLE001
484
+ fut.set_exception(exc)
485
+ fulfilled.add(row_path)
486
+ continue
487
+ fut.set_result(
488
+ RawInferenceResult(
489
+ request=req_i,
490
+ pipeline=pipe_i,
491
+ pipeline_name=pipe_i.pipeline_name,
492
+ product_type=req_i.product_type,
493
+ raw_output={
494
+ "ai_parse_document": variant,
495
+ "statement_id": response.get("statement_id"),
496
+ "batch_id": batch_id,
497
+ "batch_size_actual": len(file_mapping),
498
+ "_config": self._config_snapshot(),
499
+ },
500
+ started_at=started_at,
501
+ completed_at=completed_at,
502
+ latency_in_ms=latency_ms,
503
+ )
504
+ )
505
+ fulfilled.add(row_path)
506
+
507
+ for path, (_req, _pipe, fut) in file_mapping.items():
508
+ if path not in fulfilled and not fut.done():
509
+ fut.set_exception(ProviderPermanentError(f"Databricks batch statement returned no row for {path}"))
510
+ finally:
511
+ for path in uploaded:
512
+ self._delete_file(path)
513
+ self._delete_directory(batch_dir)
514
+
515
+ def _config_snapshot(self) -> dict[str, Any]:
516
+ return {
517
+ "version": self._version,
518
+ "description_element_types": self._description_element_types,
519
+ "warehouse_id": self._warehouse_id,
520
+ "batch_size": self._batch_size,
521
+ "batch_wait_seconds": self._batch_wait_s,
522
+ }
523
+
524
+ # ------------------------------------------------------------------ Normalize
525
+
526
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
527
+ if raw_result.product_type != ProductType.PARSE:
528
+ raise ProviderPermanentError(
529
+ f"DatabricksAiParseProvider only supports PARSE, got {raw_result.product_type}"
530
+ )
531
+
532
+ variant = raw_result.raw_output.get("ai_parse_document") or {}
533
+ document = variant.get("document") or {}
534
+ elements: list[dict[str, Any]] = document.get("elements") or []
535
+
536
+ output = ParseOutput(
537
+ task_type="parse",
538
+ example_id=raw_result.request.example_id,
539
+ pipeline_name=raw_result.pipeline_name,
540
+ pages=[],
541
+ layout_pages=_build_layout_pages(document, raw_result.request.source_file_path),
542
+ markdown=_render_markdown(elements),
543
+ )
544
+
545
+ return InferenceResult(
546
+ request=raw_result.request,
547
+ pipeline_name=raw_result.pipeline_name,
548
+ product_type=raw_result.product_type,
549
+ raw_output=raw_result.raw_output,
550
+ output=output,
551
+ started_at=raw_result.started_at,
552
+ completed_at=raw_result.completed_at,
553
+ latency_in_ms=raw_result.latency_in_ms,
554
+ )
555
+
556
+
557
+ def _primary_page_id(element: dict[str, Any]) -> int:
558
+ bboxes = element.get("bbox") or []
559
+ for box in bboxes:
560
+ pid = box.get("page_id")
561
+ if pid is not None:
562
+ try:
563
+ return int(pid)
564
+ except (TypeError, ValueError):
565
+ continue
566
+ return 0
567
+
568
+
569
+ def _render_markdown(elements: list[dict[str, Any]]) -> str:
570
+ """Concatenate element content in reading order, grouped by page."""
571
+ from collections import defaultdict
572
+
573
+ by_page: dict[int, list[dict[str, Any]]] = defaultdict(list)
574
+ for el in elements:
575
+ by_page[_primary_page_id(el)].append(el)
576
+
577
+ parts: list[str] = []
578
+ for page_id in sorted(by_page.keys()):
579
+ for el in sorted(by_page[page_id], key=lambda e: e.get("id", 0)):
580
+ content = (el.get("content") or "").strip()
581
+ if not content:
582
+ continue
583
+ el_type = (el.get("type") or "").lower()
584
+ if el_type == "title":
585
+ parts.append(f"# {content}")
586
+ elif el_type == "section_header":
587
+ parts.append(f"## {content}")
588
+ else:
589
+ parts.append(content)
590
+ return "\n\n".join(parts)
591
+
592
+
593
+ def _rendered_page_dims(source_file_path: str) -> list[tuple[float, float]] | None:
594
+ """Per-page (width, height) of ai_parse's internally rendered pages, in
595
+ the same pixel space as the returned bbox coordinates.
596
+
597
+ Image inputs are processed at native pixel dims; PDFs are rendered at
598
+ ``AI_PARSE_RENDER_DPI``, so true dims are page points * DPI/72. Returns
599
+ None when the source file is missing or unreadable (e.g. renormalizing
600
+ on a machine without the dataset) so callers can fall back gracefully.
601
+ """
602
+ path = Path(source_file_path)
603
+ if not path.is_file():
604
+ return None
605
+ try:
606
+ if path.suffix.lower() in _IMAGE_SUFFIXES:
607
+ from PIL import Image
608
+
609
+ with Image.open(path) as im:
610
+ return [(float(im.width), float(im.height))]
611
+
612
+ import fitz # PyMuPDF
613
+
614
+ scale = AI_PARSE_RENDER_DPI / 72.0
615
+ with fitz.open(path) as doc:
616
+ return [(page.rect.width * scale, page.rect.height * scale) for page in doc]
617
+ except Exception: # noqa: BLE001 — never fail normalization over page dims
618
+ return None
619
+
620
+
621
+ def _build_layout_pages(document: dict[str, Any], source_file_path: str) -> list[ParseLayoutPageIR]:
622
+ """Group elements by page and convert bboxes to normalized LayoutSegmentIR.
623
+
624
+ Coordinates are normalized into [0,1] by the true rendered page
625
+ dimensions (see ``_rendered_page_dims``). When dims can't be derived the
626
+ page falls back to normalizing by the max element extent, which keeps
627
+ boxes on-page but loses absolute position and aspect ratio.
628
+ """
629
+ from collections import defaultdict
630
+
631
+ elements: list[dict[str, Any]] = document.get("elements") or []
632
+
633
+ by_page: dict[int, list[dict[str, Any]]] = defaultdict(list)
634
+ for el in elements:
635
+ for box in el.get("bbox") or []:
636
+ page_id = box.get("page_id")
637
+ if page_id is None:
638
+ continue
639
+ try:
640
+ by_page[int(page_id)].append({"element": el, "coord": box.get("coord")})
641
+ except (TypeError, ValueError):
642
+ continue
643
+
644
+ # ai_parse page ids are 0-indexed; derive the base from document.pages
645
+ # (rather than assuming) so 1-indexed responses would still map cleanly.
646
+ declared_ids = [p.get("id") for p in document.get("pages") or [] if isinstance(p.get("id"), int)]
647
+ id_base = min(declared_ids) if declared_ids else 0
648
+
649
+ rendered_dims = _rendered_page_dims(source_file_path)
650
+
651
+ layout_pages: list[ParseLayoutPageIR] = []
652
+ for page_id in sorted(by_page.keys()):
653
+ entries = by_page[page_id]
654
+ max_x = 1.0
655
+ max_y = 1.0
656
+ for entry in entries:
657
+ coord = entry["coord"] or []
658
+ if len(coord) >= 4:
659
+ max_x = max(max_x, float(coord[2]))
660
+ max_y = max(max_y, float(coord[3]))
661
+
662
+ page_index = page_id - id_base
663
+ dims = rendered_dims[page_index] if rendered_dims is not None and 0 <= page_index < len(rendered_dims) else None
664
+ if dims is not None:
665
+ # If an element extends past the computed page edge the render-DPI
666
+ # assumption is off for this file; widen the denominator so boxes
667
+ # stay in [0,1] instead of drifting off-page.
668
+ denom_x = max(dims[0], max_x)
669
+ denom_y = max(dims[1], max_y)
670
+ page_w, page_h = dims
671
+ else:
672
+ denom_x, denom_y = max_x, max_y
673
+ page_w = page_h = _VIRTUAL_PAGE_DIM
674
+
675
+ items: list[LayoutItemIR] = []
676
+ for entry in entries:
677
+ el = entry["element"]
678
+ coord = entry["coord"] or []
679
+ if len(coord) < 4:
680
+ continue
681
+ x1, y1, x2, y2 = (float(coord[0]), float(coord[1]), float(coord[2]), float(coord[3]))
682
+ w = max(x2 - x1, 0.0)
683
+ h = max(y2 - y1, 0.0)
684
+
685
+ canonical = DATABRICKS_LABEL_MAP.get((el.get("type") or "").lower())
686
+ if canonical is None:
687
+ continue
688
+
689
+ seg = LayoutSegmentIR(
690
+ x=x1 / denom_x,
691
+ y=y1 / denom_y,
692
+ w=w / denom_x,
693
+ h=h / denom_y,
694
+ confidence=float(el.get("confidence")) if el.get("confidence") is not None else None,
695
+ label=canonical,
696
+ )
697
+
698
+ norm_label = canonical.strip().lower()
699
+ if norm_label == "table":
700
+ item_type = "table"
701
+ elif norm_label == "picture":
702
+ item_type = "image"
703
+ else:
704
+ item_type = "text"
705
+
706
+ items.append(
707
+ LayoutItemIR(
708
+ type=item_type,
709
+ value=el.get("content") or "",
710
+ bbox=seg,
711
+ layout_segments=[seg],
712
+ )
713
+ )
714
+
715
+ layout_pages.append(
716
+ ParseLayoutPageIR(
717
+ page_number=max(page_index + 1, 1),
718
+ width=page_w,
719
+ height=page_h,
720
+ items=items,
721
+ )
722
+ )
723
+
724
+ return layout_pages