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,700 @@
1
+ """Provider for Azure Document Intelligence PARSE."""
2
+
3
+ import os
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from azure.ai.documentintelligence import DocumentIntelligenceClient
9
+ from azure.ai.documentintelligence.models import AnalyzeResult
10
+ from azure.core.credentials import AzureKeyCredential
11
+
12
+ from parse_bench.inference.providers.base import (
13
+ Provider,
14
+ ProviderConfigError,
15
+ ProviderPermanentError,
16
+ ProviderTransientError,
17
+ )
18
+ from parse_bench.inference.providers.registry import register_provider
19
+ from parse_bench.schemas.parse_output import (
20
+ LayoutItemIR,
21
+ LayoutSegmentIR,
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
+ # Azure DI paragraph role -> Canonical17 label string
35
+ AZURE_DI_LABEL_MAP: dict[str, str] = {
36
+ "title": "Title",
37
+ "sectionHeading": "Section-header",
38
+ "pageHeader": "Page-header",
39
+ "pageFooter": "Page-footer",
40
+ "footnote": "Footnote",
41
+ "pageNumber": "Page-footer",
42
+ }
43
+
44
+ # Default label for paragraphs without a recognized role
45
+ _DEFAULT_PARAGRAPH_LABEL = "Text"
46
+
47
+ # Azure DI selection mark state -> Canonical17 checkbox label
48
+ AZURE_DI_SELECTION_LABEL_MAP: dict[str, str] = {
49
+ "selected": "Checkbox-Selected",
50
+ "unselected": "Checkbox-Unselected",
51
+ }
52
+
53
+ # Virtual page dimensions for normalized coordinate conversion.
54
+ # Azure DI polygons are normalized to [0,1] via page width/height, so these cancel out.
55
+ _VIRTUAL_PAGE_DIM = 1000.0
56
+
57
+
58
+ @register_provider("azure_document_intelligence")
59
+ class AzureDocumentIntelligenceProvider(Provider):
60
+ """
61
+ Provider for Azure Document Intelligence PARSE.
62
+
63
+ This provider uses Azure AI Document Intelligence for parsing tasks.
64
+ """
65
+
66
+ def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
67
+ """
68
+ Initialize the provider.
69
+
70
+ :param provider_name: Name of the provider
71
+ :param base_config: Optional configuration with:
72
+ - `api_key`: Azure Document Intelligence API key
73
+ (defaults to AZURE_DOCUMENT_INTELLIGENCE_KEY env var)
74
+ - `endpoint`: Azure Document Intelligence endpoint URL
75
+ (defaults to AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT env var)
76
+ - `model_id`: Model to use for analysis (default: "prebuilt-layout")
77
+ Options: "prebuilt-read", "prebuilt-layout", "prebuilt-document"
78
+ - `output_content_format`: Output format - "text" or "markdown"
79
+ (default: "markdown")
80
+ """
81
+ super().__init__(provider_name, base_config)
82
+
83
+ # Get API key and endpoint
84
+ self._api_key = self.base_config.get("api_key") or os.getenv("AZURE_DOCUMENT_INTELLIGENCE_KEY")
85
+ self._endpoint = self.base_config.get("endpoint") or os.getenv("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT")
86
+
87
+ if not self._api_key:
88
+ raise ProviderConfigError(
89
+ "Azure Document Intelligence API key is required. "
90
+ "Set AZURE_DOCUMENT_INTELLIGENCE_KEY environment variable "
91
+ "or pass api_key in base_config."
92
+ )
93
+
94
+ if not self._endpoint:
95
+ raise ProviderConfigError(
96
+ "Azure Document Intelligence endpoint is required. "
97
+ "Set AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT environment variable "
98
+ "or pass endpoint in base_config."
99
+ )
100
+
101
+ # Get configuration with defaults
102
+ self._model_id = self.base_config.get("model_id", "prebuilt-layout")
103
+ self._output_content_format = self.base_config.get("output_content_format", "markdown")
104
+
105
+ # Initialize client
106
+ self._client = DocumentIntelligenceClient(
107
+ endpoint=self._endpoint,
108
+ credential=AzureKeyCredential(self._api_key),
109
+ )
110
+
111
+ def _parse_pdf(self, pdf_path: str) -> dict[str, Any]:
112
+ """
113
+ Parse a PDF using Azure Document Intelligence API.
114
+
115
+ :param pdf_path: Path to the PDF file
116
+ :return: Raw API response as dictionary
117
+ :raises ProviderError: For any API errors
118
+ """
119
+ try:
120
+ # Read PDF file
121
+ with open(pdf_path, "rb") as f:
122
+ pdf_bytes = f.read()
123
+
124
+ # Analyze the document
125
+ poller = self._client.begin_analyze_document( # type: ignore[call-overload]
126
+ self._model_id,
127
+ body=pdf_bytes,
128
+ output_content_format=self._output_content_format,
129
+ )
130
+
131
+ # Wait for completion and get result
132
+ result: AnalyzeResult = poller.result()
133
+
134
+ # Convert result to dictionary for raw storage
135
+ raw_response = self._convert_result_to_dict(result)
136
+
137
+ # Store configuration for reference
138
+ raw_response["_config"] = {
139
+ "model_id": self._model_id,
140
+ "output_content_format": self._output_content_format,
141
+ }
142
+
143
+ return raw_response
144
+
145
+ except FileNotFoundError as e:
146
+ raise ProviderPermanentError(f"PDF file not found: {pdf_path}") from e
147
+ except Exception as e:
148
+ # Check if it's a transient error
149
+ error_str = str(e).lower()
150
+ transient_keywords = [
151
+ "timeout",
152
+ "network",
153
+ "connection",
154
+ "503",
155
+ "502",
156
+ "504",
157
+ "429",
158
+ "throttl",
159
+ "rate limit",
160
+ ]
161
+ if any(keyword in error_str for keyword in transient_keywords):
162
+ raise ProviderTransientError(f"Transient error during parsing: {e}") from e
163
+ else:
164
+ raise ProviderPermanentError(f"Error during parsing: {e}") from e
165
+
166
+ def _convert_result_to_dict(self, result: AnalyzeResult) -> dict[str, Any]:
167
+ """
168
+ Convert Azure Document Intelligence AnalyzeResult to dictionary.
169
+
170
+ :param result: AnalyzeResult from Azure API
171
+ :return: Dictionary representation of the result
172
+ """
173
+ response: dict[str, Any] = {}
174
+
175
+ # Extract content (full document text/markdown)
176
+ if result.content:
177
+ response["content"] = result.content
178
+
179
+ # Extract pages with their content
180
+ if result.pages:
181
+ pages_data = []
182
+ for page in result.pages:
183
+ page_dict: dict[str, Any] = {
184
+ "page_number": page.page_number,
185
+ "width": page.width,
186
+ "height": page.height,
187
+ "unit": page.unit,
188
+ }
189
+
190
+ # Extract lines if available
191
+ if page.lines:
192
+ page_dict["lines"] = [
193
+ {
194
+ "content": line.content,
195
+ "polygon": line.polygon if line.polygon else None,
196
+ }
197
+ for line in page.lines
198
+ ]
199
+
200
+ # Extract words if available
201
+ if page.words:
202
+ page_dict["words"] = [
203
+ {
204
+ "content": word.content,
205
+ "polygon": list(word.polygon) if word.polygon else None,
206
+ "confidence": word.confidence if word.confidence is not None else None,
207
+ }
208
+ for word in page.words
209
+ ]
210
+ page_dict["word_count"] = len(page.words)
211
+
212
+ # Extract selection marks (checkboxes) if available
213
+ selection_marks = getattr(page, "selection_marks", None)
214
+ if selection_marks:
215
+ page_dict["selection_marks"] = [
216
+ {
217
+ "state": mark.state,
218
+ "polygon": list(mark.polygon) if mark.polygon else None,
219
+ "confidence": mark.confidence if mark.confidence is not None else None,
220
+ }
221
+ for mark in selection_marks
222
+ ]
223
+
224
+ pages_data.append(page_dict)
225
+
226
+ response["pages"] = pages_data
227
+
228
+ # Extract tables if available
229
+ if result.tables:
230
+ tables_data = []
231
+ for table in result.tables:
232
+ table_dict: dict[str, Any] = {
233
+ "row_count": table.row_count,
234
+ "column_count": table.column_count,
235
+ "cells": [],
236
+ }
237
+
238
+ if table.cells:
239
+ for cell in table.cells:
240
+ cell_dict: dict[str, Any] = {
241
+ "row_index": cell.row_index,
242
+ "column_index": cell.column_index,
243
+ "content": cell.content,
244
+ "row_span": cell.row_span,
245
+ "column_span": cell.column_span,
246
+ # Azure tags structural roles via cell.kind:
247
+ # "columnHeader" / "rowHeader" / "stubHead" / "description"
248
+ # / "content" (default). Used to choose <th> vs <td>
249
+ # when reconstructing table HTML downstream.
250
+ "kind": getattr(cell, "kind", None),
251
+ }
252
+ # Per-cell polygons let downstream tooling overlay
253
+ # individual cell bboxes. Some Azure tables only carry a
254
+ # table-level region, so this is best-effort optional.
255
+ cell_bounding_regions = getattr(cell, "bounding_regions", None)
256
+ if cell_bounding_regions:
257
+ cell_dict["bounding_regions"] = [
258
+ {
259
+ "page_number": br.page_number,
260
+ "polygon": list(br.polygon) if br.polygon else None,
261
+ }
262
+ for br in cell_bounding_regions
263
+ ]
264
+ table_dict["cells"].append(cell_dict)
265
+
266
+ tables_data.append(table_dict)
267
+
268
+ response["tables"] = tables_data
269
+
270
+ # Extract paragraphs if available (with bounding regions for layout)
271
+ if result.paragraphs:
272
+ paragraphs_data = []
273
+ for para in result.paragraphs:
274
+ para_dict: dict[str, Any] = {
275
+ "content": para.content,
276
+ "role": para.role if para.role else None,
277
+ }
278
+ if para.bounding_regions:
279
+ para_dict["bounding_regions"] = [
280
+ {
281
+ "page_number": br.page_number,
282
+ "polygon": list(br.polygon) if br.polygon else None,
283
+ }
284
+ for br in para.bounding_regions
285
+ ]
286
+ paragraphs_data.append(para_dict)
287
+ response["paragraphs"] = paragraphs_data
288
+
289
+ # Extract tables if available (with bounding regions for layout)
290
+ if result.tables:
291
+ for i, table in enumerate(result.tables):
292
+ if table.bounding_regions and i < len(response.get("tables", [])):
293
+ response["tables"][i]["bounding_regions"] = [
294
+ {
295
+ "page_number": br.page_number,
296
+ "polygon": list(br.polygon) if br.polygon else None,
297
+ }
298
+ for br in table.bounding_regions
299
+ ]
300
+
301
+ # Extract figures if available (with bounding regions for layout)
302
+ if result.figures:
303
+ response["figures"] = [
304
+ {
305
+ "caption": fig.caption.content if fig.caption else None,
306
+ "bounding_regions": [
307
+ {
308
+ "page_number": br.page_number,
309
+ "polygon": list(br.polygon) if br.polygon else None,
310
+ }
311
+ for br in fig.bounding_regions
312
+ ]
313
+ if fig.bounding_regions
314
+ else [],
315
+ }
316
+ for fig in result.figures
317
+ ]
318
+
319
+ # Extract key-value pairs if available
320
+ if result.key_value_pairs:
321
+ response["key_value_pairs"] = [
322
+ {
323
+ "key": kvp.key.content if kvp.key else None,
324
+ "value": kvp.value.content if kvp.value else None,
325
+ }
326
+ for kvp in result.key_value_pairs
327
+ ]
328
+
329
+ return response
330
+
331
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
332
+ """
333
+ Run inference and return raw results.
334
+
335
+ :param pipeline: Pipeline specification
336
+ :param request: Inference request
337
+ :return: Raw inference result
338
+ :raises ProviderError: For any provider-related failures
339
+ """
340
+ if request.product_type != ProductType.PARSE:
341
+ raise ProviderPermanentError(
342
+ f"AzureDocumentIntelligenceProvider only supports PARSE product type, got {request.product_type}"
343
+ )
344
+
345
+ started_at = datetime.now()
346
+
347
+ # Check if file exists
348
+ pdf_path = Path(request.source_file_path)
349
+ if not pdf_path.exists():
350
+ raise ProviderPermanentError(f"PDF file not found: {pdf_path}")
351
+
352
+ try:
353
+ # Run parsing
354
+ raw_output = self._parse_pdf(str(pdf_path))
355
+
356
+ completed_at = datetime.now()
357
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
358
+
359
+ return RawInferenceResult(
360
+ request=request,
361
+ pipeline=pipeline,
362
+ pipeline_name=pipeline.pipeline_name,
363
+ product_type=request.product_type,
364
+ raw_output=raw_output,
365
+ started_at=started_at,
366
+ completed_at=completed_at,
367
+ latency_in_ms=latency_ms,
368
+ )
369
+
370
+ except (ProviderPermanentError, ProviderTransientError, ProviderConfigError):
371
+ raise
372
+ except Exception as e:
373
+ raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
374
+
375
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
376
+ """
377
+ Normalize raw inference result to produce ParseOutput.
378
+
379
+ :param raw_result: Raw inference result from run_inference()
380
+ :return: Inference result with both raw and normalized outputs
381
+ :raises ProviderError: For any normalization failures
382
+ """
383
+ if raw_result.product_type != ProductType.PARSE:
384
+ raise ProviderPermanentError(
385
+ f"AzureDocumentIntelligenceProvider only supports PARSE product type, got {raw_result.product_type}"
386
+ )
387
+
388
+ # Extract the main content (markdown or text)
389
+ content = raw_result.raw_output.get("content", "")
390
+
391
+ # Build page-level data if available
392
+ pages: list[PageIR] = []
393
+ raw_pages = raw_result.raw_output.get("pages", [])
394
+
395
+ if raw_pages:
396
+ # Azure returns page boundaries, we can try to split content by pages
397
+ # For now, we'll create page entries with line-based content
398
+ for page_data in raw_pages:
399
+ page_num = page_data.get("page_number", 1)
400
+ page_index = page_num - 1 # Convert to 0-indexed
401
+
402
+ # Reconstruct page content from lines if available
403
+ page_content = ""
404
+ if "lines" in page_data:
405
+ page_content = "\n".join(line.get("content", "") for line in page_data.get("lines", []))
406
+
407
+ pages.append(PageIR(page_index=page_index, markdown=page_content))
408
+
409
+ # Build layout_pages for layout cross-evaluation
410
+ layout_pages = _build_layout_pages(raw_result.raw_output)
411
+
412
+ output = ParseOutput(
413
+ task_type="parse",
414
+ example_id=raw_result.request.example_id,
415
+ pipeline_name=raw_result.pipeline_name,
416
+ pages=pages,
417
+ layout_pages=layout_pages,
418
+ markdown=content,
419
+ )
420
+
421
+ return InferenceResult(
422
+ request=raw_result.request,
423
+ pipeline_name=raw_result.pipeline_name,
424
+ product_type=raw_result.product_type,
425
+ raw_output=raw_result.raw_output,
426
+ output=output,
427
+ started_at=raw_result.started_at,
428
+ completed_at=raw_result.completed_at,
429
+ latency_in_ms=raw_result.latency_in_ms,
430
+ )
431
+
432
+
433
+ def _polygon_to_normalized_bbox(
434
+ polygon: list[float],
435
+ page_width: float,
436
+ page_height: float,
437
+ ) -> tuple[float, float, float, float]:
438
+ """Convert Azure DI polygon (8 floats, 4 corner points in page units) to normalized [0,1] xywh.
439
+
440
+ The polygon contains [x1,y1, x2,y2, x3,y3, x4,y4] in the page's coordinate
441
+ system (typically inches). We take min/max to get axis-aligned bbox, then
442
+ normalize by page dimensions.
443
+ """
444
+ xs = [polygon[i] for i in range(0, len(polygon), 2)]
445
+ ys = [polygon[i] for i in range(1, len(polygon), 2)]
446
+ x_min = min(xs)
447
+ y_min = min(ys)
448
+ x_max = max(xs)
449
+ y_max = max(ys)
450
+
451
+ # Normalize to [0, 1]
452
+ nx = x_min / page_width if page_width > 0 else 0.0
453
+ ny = y_min / page_height if page_height > 0 else 0.0
454
+ nw = (x_max - x_min) / page_width if page_width > 0 else 0.0
455
+ nh = (y_max - y_min) / page_height if page_height > 0 else 0.0
456
+
457
+ return (nx, ny, nw, nh)
458
+
459
+
460
+ def _build_table_html_from_cells(
461
+ cells: list[dict[str, Any]],
462
+ row_count: int,
463
+ column_count: int,
464
+ ) -> str:
465
+ """Reconstruct a ``<table>`` HTML string from Azure DI cell dicts.
466
+
467
+ Honors ``row_span`` / ``column_span`` (cells covered by a previous
468
+ cell's span are skipped, not double-emitted) and Azure's ``cell.kind``
469
+ (``"columnHeader"`` / ``"rowHeader"`` → ``<th>``; everything else →
470
+ ``<td>``). Cells without integer ``row_index`` / ``column_index`` are
471
+ skipped silently — they're structural only.
472
+
473
+ Returns ``""`` when input is degenerate (no cells, missing row/col
474
+ counts) so callers can fall back to flat-text content.
475
+ """
476
+ from html import escape as _html_escape
477
+
478
+ if not cells or row_count <= 0 or column_count <= 0:
479
+ return ""
480
+
481
+ grid: dict[tuple[int, int], dict[str, Any]] = {}
482
+ for cell in cells:
483
+ r = cell.get("row_index")
484
+ c = cell.get("column_index")
485
+ if isinstance(r, int) and isinstance(c, int):
486
+ grid[(r, c)] = cell
487
+
488
+ covered: set[tuple[int, int]] = set()
489
+ rows_html: list[str] = []
490
+ for r in range(row_count):
491
+ cells_html: list[str] = []
492
+ for c in range(column_count):
493
+ if (r, c) in covered:
494
+ continue
495
+ cell_at_pos = grid.get((r, c))
496
+ if cell_at_pos is None:
497
+ cells_html.append("<td></td>")
498
+ continue
499
+ row_span = int(cell_at_pos.get("row_span") or 1)
500
+ col_span = int(cell_at_pos.get("column_span") or 1)
501
+ for dr in range(row_span):
502
+ for dc in range(col_span):
503
+ if dr == 0 and dc == 0:
504
+ continue
505
+ covered.add((r + dr, c + dc))
506
+ attrs = ""
507
+ if row_span > 1:
508
+ attrs += f' rowspan="{row_span}"'
509
+ if col_span > 1:
510
+ attrs += f' colspan="{col_span}"'
511
+ kind = (cell_at_pos.get("kind") or "").strip()
512
+ tag = "th" if kind in ("columnHeader", "rowHeader") else "td"
513
+ content = _html_escape(cell_at_pos.get("content") or "")
514
+ cells_html.append(f"<{tag}{attrs}>{content}</{tag}>")
515
+ # Always emit a <tr> for every row in row_count, even when every
516
+ # position is covered by a rowspan from a previous row. HTML
517
+ # counts <tr> elements to resolve rowspan targets — dropping
518
+ # the empty <tr> would push the next row's cells into the wrong
519
+ # row index. The empty <tr></tr> renders as an invisible row in
520
+ # browsers and parses cleanly under lxml + BeautifulSoup, which
521
+ # is what the TEDS evaluator uses.
522
+ rows_html.append("<tr>" + "".join(cells_html) + "</tr>")
523
+
524
+ return "<table>" + "".join(rows_html) + "</table>"
525
+
526
+
527
+ def _bbox_center_inside_any(
528
+ bbox: tuple[float, float, float, float],
529
+ others: list[tuple[float, float, float, float]],
530
+ ) -> bool:
531
+ """``True`` when the center of ``bbox`` lies inside any rectangle in
532
+ ``others``. Both rectangles are normalized xywh in the same coord
533
+ system. Used to filter Azure DI cell-as-paragraph duplicates.
534
+ """
535
+ cx = bbox[0] + bbox[2] / 2.0
536
+ cy = bbox[1] + bbox[3] / 2.0
537
+ for ox, oy, ow, oh in others:
538
+ if ox <= cx <= ox + ow and oy <= cy <= oy + oh:
539
+ return True
540
+ return False
541
+
542
+
543
+ def _build_layout_pages(raw_output: dict[str, Any]) -> list[ParseLayoutPageIR]:
544
+ """Build layout_pages from Azure DI paragraphs/tables/figures for layout cross-evaluation.
545
+
546
+ Groups elements by page using bounding_regions and converts Azure DI polygon
547
+ coordinates (in page units) into normalized [0,1] LayoutSegmentIR entries.
548
+ """
549
+ from collections import defaultdict
550
+
551
+ # Build page dimension lookup from pages data
552
+ page_dims: dict[int, tuple[float, float]] = {}
553
+ for page_data in raw_output.get("pages", []):
554
+ page_num = page_data.get("page_number", 1)
555
+ width = float(page_data.get("width", 1.0))
556
+ height = float(page_data.get("height", 1.0))
557
+ page_dims[page_num] = (width, height)
558
+
559
+ # Collect all layout elements grouped by page:
560
+ # (canonical_label, nx, ny, nw, nh, value_text, confidence, html_or_empty).
561
+ # html_or_empty is populated only for table items; other element kinds
562
+ # push an empty string so the tuple stays uniform.
563
+ pages_items: dict[int, list[tuple[str, float, float, float, float, str, float, str]]] = defaultdict(list)
564
+
565
+ # Pre-compute table rectangles per page so we can skip paragraphs
566
+ # whose bbox sits inside one. Azure DI returns each table cell *both*
567
+ # as a ``tables[*].cells`` entry (which we use for HTML
568
+ # reconstruction) and as a standalone ``paragraphs[]`` entry —
569
+ # typically with no role and the same bbox as the cell. Without this
570
+ # filter the layout output ends up with one Text bbox per cell on
571
+ # top of the parent Table region.
572
+ table_rects_by_page: dict[int, list[tuple[float, float, float, float]]] = defaultdict(list)
573
+ for table in raw_output.get("tables", []):
574
+ for br in table.get("bounding_regions", []):
575
+ page_num = br.get("page_number", 1)
576
+ polygon = br.get("polygon")
577
+ if not polygon or len(polygon) < 8:
578
+ continue
579
+ pw, ph = page_dims.get(page_num, (1.0, 1.0))
580
+ table_rects_by_page[page_num].append(_polygon_to_normalized_bbox(polygon, pw, ph))
581
+
582
+ # Process paragraphs
583
+ for para in raw_output.get("paragraphs", []):
584
+ role = para.get("role")
585
+ canonical_label = AZURE_DI_LABEL_MAP.get(role, _DEFAULT_PARAGRAPH_LABEL) if role else _DEFAULT_PARAGRAPH_LABEL
586
+ content = para.get("content", "")
587
+
588
+ for br in para.get("bounding_regions", []):
589
+ page_num = br.get("page_number", 1)
590
+ polygon = br.get("polygon")
591
+ if not polygon or len(polygon) < 8:
592
+ continue
593
+ pw, ph = page_dims.get(page_num, (1.0, 1.0))
594
+ nx, ny, nw, nh = _polygon_to_normalized_bbox(polygon, pw, ph)
595
+ if _bbox_center_inside_any((nx, ny, nw, nh), table_rects_by_page.get(page_num, [])):
596
+ # Cell-as-paragraph duplicate. Cell content already lives
597
+ # in the table region's HTML / value via
598
+ # _build_table_html_from_cells; emitting it again would
599
+ # double-count.
600
+ continue
601
+ pages_items[page_num].append((canonical_label, nx, ny, nw, nh, content, 1.0, ""))
602
+
603
+ # Process tables
604
+ for table in raw_output.get("tables", []):
605
+ # Flat text fallback for the LayoutItemIR.value field — kept for
606
+ # compatibility with callers that don't read .html. Structured
607
+ # HTML reconstruction (handed to LayoutItemIR.html) preserves
608
+ # <th>/<td> roles and spans for table metrics.
609
+ cells = table.get("cells", [])
610
+ content = " ".join(c.get("content", "") for c in cells if c.get("content"))
611
+ html_content = _build_table_html_from_cells(
612
+ cells,
613
+ int(table.get("row_count") or 0),
614
+ int(table.get("column_count") or 0),
615
+ )
616
+
617
+ for br in table.get("bounding_regions", []):
618
+ page_num = br.get("page_number", 1)
619
+ polygon = br.get("polygon")
620
+ if not polygon or len(polygon) < 8:
621
+ continue
622
+ pw, ph = page_dims.get(page_num, (1.0, 1.0))
623
+ nx, ny, nw, nh = _polygon_to_normalized_bbox(polygon, pw, ph)
624
+ pages_items[page_num].append(("Table", nx, ny, nw, nh, content, 1.0, html_content))
625
+
626
+ # Process figures
627
+ for fig in raw_output.get("figures", []):
628
+ caption = fig.get("caption") or ""
629
+ for br in fig.get("bounding_regions", []):
630
+ page_num = br.get("page_number", 1)
631
+ polygon = br.get("polygon")
632
+ if not polygon or len(polygon) < 8:
633
+ continue
634
+ pw, ph = page_dims.get(page_num, (1.0, 1.0))
635
+ nx, ny, nw, nh = _polygon_to_normalized_bbox(polygon, pw, ph)
636
+ pages_items[page_num].append(("Picture", nx, ny, nw, nh, caption, 1.0, ""))
637
+
638
+ # Process selection marks (checkboxes) — live on page objects, not at document root
639
+ for page_data in raw_output.get("pages", []):
640
+ page_num = page_data.get("page_number", 1)
641
+ pw, ph = page_dims.get(page_num, (1.0, 1.0))
642
+ for mark in page_data.get("selection_marks", []) or []:
643
+ state = mark.get("state") or ""
644
+ checkbox_label = AZURE_DI_SELECTION_LABEL_MAP.get(state)
645
+ if checkbox_label is None:
646
+ continue
647
+ polygon = mark.get("polygon")
648
+ if not polygon or len(polygon) < 8:
649
+ continue
650
+ nx, ny, nw, nh = _polygon_to_normalized_bbox(polygon, pw, ph)
651
+ confidence = mark.get("confidence")
652
+ confidence_val = float(confidence) if confidence is not None else 1.0
653
+ pages_items[page_num].append((checkbox_label, nx, ny, nw, nh, "", confidence_val, ""))
654
+
655
+ # Build ParseLayoutPageIR list
656
+ layout_pages: list[ParseLayoutPageIR] = []
657
+ for page_num in sorted(pages_items.keys()):
658
+ items_data = pages_items[page_num]
659
+ items: list[LayoutItemIR] = []
660
+
661
+ for canonical_label, nx, ny, nw, nh, content, confidence, html_content in items_data:
662
+ seg = LayoutSegmentIR(
663
+ x=nx,
664
+ y=ny,
665
+ w=nw,
666
+ h=nh,
667
+ confidence=confidence,
668
+ label=canonical_label,
669
+ )
670
+
671
+ norm_label = canonical_label.strip().lower()
672
+ if norm_label == "table":
673
+ item_type = "table"
674
+ elif norm_label == "picture":
675
+ item_type = "image"
676
+ elif canonical_label in AZURE_DI_SELECTION_LABEL_MAP.values():
677
+ item_type = "checkbox"
678
+ else:
679
+ item_type = "text"
680
+
681
+ items.append(
682
+ LayoutItemIR(
683
+ type=item_type,
684
+ value=content,
685
+ html=html_content,
686
+ bbox=seg,
687
+ layout_segments=[seg],
688
+ )
689
+ )
690
+
691
+ layout_pages.append(
692
+ ParseLayoutPageIR(
693
+ page_number=page_num,
694
+ width=_VIRTUAL_PAGE_DIM,
695
+ height=_VIRTUAL_PAGE_DIM,
696
+ items=items,
697
+ )
698
+ )
699
+
700
+ return layout_pages