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,203 @@
1
+ """Base class for HuggingFace layout detection providers."""
2
+
3
+ import io
4
+ import os
5
+ from abc import abstractmethod
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ import requests
10
+ from PIL import Image
11
+
12
+ from parse_bench.inference.providers.base import (
13
+ Provider,
14
+ ProviderConfigError,
15
+ ProviderPermanentError,
16
+ ProviderRateLimitError,
17
+ ProviderTransientError,
18
+ )
19
+ from parse_bench.schemas.layout_detection_output import (
20
+ LayoutDetectionModel,
21
+ LayoutPrediction,
22
+ )
23
+ from parse_bench.schemas.pipeline import PipelineSpec
24
+ from parse_bench.schemas.pipeline_io import (
25
+ InferenceRequest,
26
+ InferenceResult,
27
+ RawInferenceResult,
28
+ )
29
+ from parse_bench.schemas.product import ProductType
30
+
31
+
32
+ def image_to_bytes(image: Image.Image, format: str = "PNG") -> bytes:
33
+ """Convert PIL Image to bytes."""
34
+ buffer = io.BytesIO()
35
+ image.save(buffer, format=format)
36
+ buffer.seek(0)
37
+ return buffer.read()
38
+
39
+
40
+ class HFLayoutDetProvider(Provider):
41
+ """
42
+ Base class for HuggingFace layout detection inference providers.
43
+
44
+ Subclasses must set:
45
+ - endpoint_url: The HF inference endpoint URL (class attribute)
46
+ - model_type: The LayoutDetectionModel enum value
47
+
48
+ Subclasses must implement:
49
+ - _parse_response(): Convert raw API response to list of predictions
50
+ - normalize(): Convert raw result to LayoutOutput
51
+ """
52
+
53
+ # Subclasses must override these
54
+ endpoint_url: str = ""
55
+ model_type: LayoutDetectionModel
56
+
57
+ def __init__(
58
+ self,
59
+ provider_name: str,
60
+ base_config: dict[str, Any] | None = None,
61
+ ):
62
+ """
63
+ Initialize the HF layout detection provider.
64
+
65
+ :param provider_name: Name of the provider
66
+ :param base_config: Optional configuration with:
67
+ - `hf_token`: HuggingFace token (defaults to HF_TOKEN env var)
68
+ - `timeout`: Request timeout in seconds (default: 60)
69
+ """
70
+ super().__init__(provider_name, base_config)
71
+
72
+ # Get HF token
73
+ self._hf_token = self.base_config.get("hf_token") or os.getenv("HF_TOKEN")
74
+ if not self._hf_token:
75
+ raise ProviderConfigError(
76
+ "HuggingFace token is required. Set HF_TOKEN environment variable or pass hf_token in base_config."
77
+ )
78
+
79
+ # Get timeout (default 60 seconds)
80
+ self._timeout = self.base_config.get("timeout", 60)
81
+
82
+ def _call_endpoint(self, image: Image.Image) -> dict[str, Any]:
83
+ """
84
+ Call the HuggingFace inference endpoint with an image.
85
+
86
+ :param image: PIL Image to send
87
+ :return: Raw JSON response from the endpoint
88
+ :raises ProviderError: For any API errors
89
+ """
90
+ if not self.endpoint_url:
91
+ raise ProviderConfigError("Endpoint URL not configured")
92
+
93
+ headers = {
94
+ "Authorization": f"Bearer {self._hf_token}",
95
+ "Content-Type": "image/png",
96
+ }
97
+
98
+ image_bytes = image_to_bytes(image, format="PNG")
99
+
100
+ try:
101
+ response = requests.post(
102
+ self.endpoint_url,
103
+ headers=headers,
104
+ data=image_bytes,
105
+ timeout=self._timeout,
106
+ )
107
+ response.raise_for_status()
108
+ return response.json() # type: ignore[no-any-return]
109
+
110
+ except requests.exceptions.Timeout as e:
111
+ raise ProviderTransientError(f"Request timed out: {e}") from e
112
+ except requests.exceptions.ConnectionError as e:
113
+ raise ProviderTransientError(f"Connection error: {e}") from e
114
+ except requests.exceptions.HTTPError as e:
115
+ status_code = e.response.status_code if e.response else None
116
+ if status_code == 429:
117
+ raise ProviderRateLimitError(f"Rate limit exceeded: {e}") from e
118
+ elif status_code and 500 <= status_code < 600:
119
+ raise ProviderTransientError(f"Server error ({status_code}): {e}") from e
120
+ elif status_code and 400 <= status_code < 500:
121
+ raise ProviderPermanentError(f"Client error ({status_code}): {e}") from e
122
+ else:
123
+ raise ProviderPermanentError(f"HTTP error: {e}") from e
124
+ except Exception as e:
125
+ raise ProviderPermanentError(f"Unexpected error calling endpoint: {e}") from e
126
+
127
+ @abstractmethod
128
+ def _parse_response(self, response: dict[str, Any]) -> list[LayoutPrediction]:
129
+ """
130
+ Parse the raw API response into a list of layout predictions.
131
+
132
+ Subclasses must implement this to handle model-specific response formats.
133
+
134
+ :param response: Raw JSON response from the endpoint
135
+ :return: List of layout predictions
136
+ """
137
+ raise NotImplementedError("Subclasses must implement _parse_response")
138
+
139
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
140
+ """
141
+ Run layout detection inference on an image.
142
+
143
+ :param pipeline: Pipeline specification
144
+ :param request: Inference request (source_file_path should be an image)
145
+ :return: Raw inference result
146
+ :raises ProviderError: For any provider-related failures
147
+ """
148
+ if request.product_type != ProductType.LAYOUT_DETECTION:
149
+ raise ProviderPermanentError(
150
+ f"{self.__class__.__name__} only supports LAYOUT_DETECTION product type, got {request.product_type}"
151
+ )
152
+
153
+ started_at = datetime.now()
154
+
155
+ # Load the image
156
+ try:
157
+ image = Image.open(request.source_file_path)
158
+ # Ensure image is in RGB mode for PNG conversion
159
+ if image.mode not in ("RGB", "RGBA"):
160
+ image = image.convert("RGB") # type: ignore[assignment]
161
+ except Exception as e:
162
+ raise ProviderPermanentError(f"Failed to load image: {e}") from e
163
+
164
+ # Get image dimensions
165
+ image_width, image_height = image.size
166
+
167
+ # Call the endpoint
168
+ raw_response = self._call_endpoint(image)
169
+
170
+ completed_at = datetime.now()
171
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
172
+
173
+ # Store image dimensions in raw output for normalization
174
+ raw_output = {
175
+ "response": raw_response,
176
+ "image_width": image_width,
177
+ "image_height": image_height,
178
+ }
179
+
180
+ return RawInferenceResult(
181
+ request=request,
182
+ pipeline=pipeline,
183
+ pipeline_name=pipeline.pipeline_name,
184
+ product_type=request.product_type,
185
+ raw_output=raw_output,
186
+ started_at=started_at,
187
+ completed_at=completed_at,
188
+ latency_in_ms=latency_ms,
189
+ )
190
+
191
+ @abstractmethod
192
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
193
+ """
194
+ Normalize raw inference result to produce LayoutOutput.
195
+
196
+ Each subclass MUST implement this method to return normalized
197
+ provider predictions in the common layout interface.
198
+
199
+ :param raw_result: Raw inference result from run_inference()
200
+ :return: Inference result with both raw and normalized outputs
201
+ :raises ProviderError: For any normalization failures
202
+ """
203
+ raise NotImplementedError("Subclasses must implement normalize")
@@ -0,0 +1,449 @@
1
+ """Provider for Chandra OCR layout detection via Modal OpenAI-compatible API."""
2
+
3
+ import base64
4
+ import io
5
+ import json
6
+ import logging
7
+ import re
8
+ from datetime import datetime
9
+ from typing import Any
10
+
11
+ from openai import OpenAI
12
+ from PIL import Image
13
+
14
+ from parse_bench.inference.providers.base import (
15
+ Provider,
16
+ ProviderPermanentError,
17
+ ProviderTransientError,
18
+ )
19
+ from parse_bench.inference.providers.registry import register_provider
20
+ from parse_bench.schemas.layout_detection_output import (
21
+ CHANDRA_STR_TO_LABEL,
22
+ LayoutDetectionModel,
23
+ LayoutOutput,
24
+ LayoutPrediction,
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
+ logger = logging.getLogger(__name__)
35
+
36
+ # Allowed HTML tags for Chandra output
37
+ ALLOWED_TAGS = [
38
+ "math",
39
+ "br",
40
+ "i",
41
+ "b",
42
+ "u",
43
+ "del",
44
+ "sup",
45
+ "sub",
46
+ "table",
47
+ "tr",
48
+ "td",
49
+ "p",
50
+ "th",
51
+ "div",
52
+ "pre",
53
+ "h1",
54
+ "h2",
55
+ "h3",
56
+ "h4",
57
+ "h5",
58
+ "ul",
59
+ "ol",
60
+ "li",
61
+ "input",
62
+ "a",
63
+ "span",
64
+ "img",
65
+ "hr",
66
+ "tbody",
67
+ "small",
68
+ "caption",
69
+ "strong",
70
+ "thead",
71
+ "big",
72
+ "code",
73
+ ]
74
+
75
+ ALLOWED_ATTRIBUTES = [
76
+ "class",
77
+ "colspan",
78
+ "rowspan",
79
+ "display",
80
+ "checked",
81
+ "type",
82
+ "border",
83
+ "value",
84
+ "style",
85
+ "href",
86
+ "alt",
87
+ "align",
88
+ ]
89
+
90
+ PROMPT_ENDING = (
91
+ f"Only use these tags {ALLOWED_TAGS}, "
92
+ f"and these attributes {ALLOWED_ATTRIBUTES}."
93
+ "\n\nGuidelines:\n"
94
+ "* Inline math: Surround math with <math>...</math> "
95
+ "tags. Math expressions should be rendered in "
96
+ "KaTeX-compatible LaTeX. Use display for block "
97
+ "math.\n"
98
+ "* Tables: Use colspan and rowspan attributes to "
99
+ "match table structure.\n"
100
+ "* Formatting: Maintain consistent formatting with "
101
+ "the image, including spacing, indentation, "
102
+ "subscripts/superscripts, and special characters.\n"
103
+ "* Images: Include a description of any images in "
104
+ "the alt attribute of an <img> tag. Do not fill out "
105
+ "the src property.\n"
106
+ "* Forms: Mark checkboxes and radio buttons "
107
+ "properly.\n"
108
+ "* Text: join lines together properly into paragraphs "
109
+ "using <p>...</p> tags. Use <br> tags for line "
110
+ "breaks within paragraphs, but only when absolutely "
111
+ "necessary to maintain meaning.\n"
112
+ "* Use the simplest possible HTML structure that "
113
+ "accurately represents the content of the block.\n"
114
+ "* Make sure the text is accurate and easy for a "
115
+ "human to read and interpret. Reading order should "
116
+ "be correct and natural."
117
+ )
118
+
119
+
120
+ @register_provider("chandra_layout")
121
+ class ChandraLayoutProvider(Provider):
122
+ """
123
+ Layout detection using Chandra OCR via Modal OpenAI-compatible API.
124
+
125
+ This provider sends images to the Chandra model using the ocr_layout prompt
126
+ and parses the HTML response containing layout blocks with bounding boxes.
127
+
128
+ Response format (HTML):
129
+ <div data-bbox="[x0, y0, x1, y1]" data-label="Text">content...</div>
130
+ <div data-bbox="[x0, y0, x1, y1]" data-label="Table">...</div>
131
+ ...
132
+
133
+ Coordinates are normalized to [0-1024] and converted to pixel coords.
134
+ """
135
+
136
+ DEFAULT_ENDPOINT_URL = "https://llamaindex--chandra-vllm-inference-serve.modal.run/v1"
137
+ model_type = LayoutDetectionModel.CHANDRA
138
+
139
+ # Chandra uses 0-1024 normalized bbox coordinates
140
+ BBOX_SCALE = 1024
141
+
142
+ def __init__(
143
+ self,
144
+ provider_name: str,
145
+ base_config: dict[str, Any] | None = None,
146
+ ):
147
+ """Initialize the Chandra layout detection provider."""
148
+ super().__init__(provider_name, base_config)
149
+
150
+ # Get endpoint URL from config or use default
151
+ endpoint_url = self.base_config.get("endpoint_url", self.DEFAULT_ENDPOINT_URL)
152
+
153
+ # Initialize OpenAI client for Modal endpoint
154
+ self._client = OpenAI(
155
+ base_url=endpoint_url,
156
+ api_key="not-needed",
157
+ )
158
+
159
+ # Get timeout (default 180 seconds for VLM - Chandra can be slow)
160
+ self._timeout = self.base_config.get("timeout", 180)
161
+
162
+ # Build the prompt with bbox_scale
163
+ self._prompt = self._build_ocr_layout_prompt()
164
+
165
+ def _build_ocr_layout_prompt(self) -> str:
166
+ """Build the OCR layout prompt with the correct bbox_scale."""
167
+ return (
168
+ "OCR this image to HTML, arranged as layout "
169
+ "blocks. Each layout block should be a div "
170
+ "with the data-bbox attribute representing the "
171
+ "bounding box of the block in "
172
+ "[x0, y0, x1, y1] format. "
173
+ f"Bboxes are normalized 0-{self.BBOX_SCALE}. "
174
+ "The data-label attribute is the label for "
175
+ "the block."
176
+ "\n\nUse the following labels:\n"
177
+ "- Caption\n"
178
+ "- Footnote\n"
179
+ "- Equation-Block\n"
180
+ "- List-Group\n"
181
+ "- Page-Header\n"
182
+ "- Page-Footer\n"
183
+ "- Image\n"
184
+ "- Section-Header\n"
185
+ "- Table\n"
186
+ "- Text\n"
187
+ "- Complex-Block\n"
188
+ "- Code-Block\n"
189
+ "- Form\n"
190
+ "- Table-Of-Contents\n"
191
+ "- Figure"
192
+ f"\n\n{PROMPT_ENDING}"
193
+ ).strip()
194
+
195
+ def _image_to_base64(self, image: Image.Image) -> str:
196
+ """Convert PIL Image to base64 string."""
197
+ buffer = io.BytesIO()
198
+ image.save(buffer, format="PNG")
199
+ buffer.seek(0)
200
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
201
+
202
+ def _parse_html_layout_blocks(self, html_content: str) -> list[dict[str, Any]]:
203
+ """
204
+ Parse HTML content to extract layout blocks with data-bbox and data-label.
205
+
206
+ :param html_content: Raw HTML response from Chandra
207
+ :return: List of dicts with 'bbox', 'label' keys
208
+ """
209
+ predictions: list[dict[str, Any]] = []
210
+
211
+ # Pattern to match divs with data-bbox and data-label attributes
212
+ # Handles both attribute orders: data-bbox first or data-label first
213
+ pattern = r'<div[^>]*data-bbox=["\'](\[[^\]]+\])["\'][^>]*data-label=["\']([^"\']+)["\']'
214
+ pattern_alt = r'<div[^>]*data-label=["\']([^"\']+)["\'][^>]*data-bbox=["\'](\[[^\]]+\])["\']'
215
+
216
+ # Find all matches with data-bbox first
217
+ for match in re.finditer(pattern, html_content):
218
+ bbox_str = match.group(1)
219
+ label = match.group(2)
220
+ try:
221
+ bbox = json.loads(bbox_str)
222
+ if isinstance(bbox, list) and len(bbox) == 4:
223
+ predictions.append(
224
+ {
225
+ "bbox": bbox,
226
+ "label": label,
227
+ }
228
+ )
229
+ except json.JSONDecodeError:
230
+ logger.warning(f"Failed to parse bbox: {bbox_str}")
231
+ continue
232
+
233
+ # Find all matches with data-label first
234
+ for match in re.finditer(pattern_alt, html_content):
235
+ label = match.group(1)
236
+ bbox_str = match.group(2)
237
+ try:
238
+ bbox = json.loads(bbox_str)
239
+ if isinstance(bbox, list) and len(bbox) == 4:
240
+ predictions.append(
241
+ {
242
+ "bbox": bbox,
243
+ "label": label,
244
+ }
245
+ )
246
+ except json.JSONDecodeError:
247
+ logger.warning(f"Failed to parse bbox: {bbox_str}")
248
+ continue
249
+
250
+ return predictions
251
+
252
+ def _normalize_bbox(
253
+ self,
254
+ bbox_normalized: list[float],
255
+ image_width: int,
256
+ image_height: int,
257
+ ) -> list[float]:
258
+ """
259
+ Convert [0-1024] normalized coords to pixel coords.
260
+
261
+ :param bbox_normalized: Bounding box in [0-1024] normalized coords
262
+ :param image_width: Actual image width in pixels
263
+ :param image_height: Actual image height in pixels
264
+ :return: Bounding box in pixel coordinates [x1, y1, x2, y2]
265
+ """
266
+ x1, y1, x2, y2 = bbox_normalized
267
+
268
+ # Clamp to valid range
269
+ x1 = max(0, min(self.BBOX_SCALE, x1))
270
+ y1 = max(0, min(self.BBOX_SCALE, y1))
271
+ x2 = max(0, min(self.BBOX_SCALE, x2))
272
+ y2 = max(0, min(self.BBOX_SCALE, y2))
273
+
274
+ return [
275
+ x1 * image_width / self.BBOX_SCALE,
276
+ y1 * image_height / self.BBOX_SCALE,
277
+ x2 * image_width / self.BBOX_SCALE,
278
+ y2 * image_height / self.BBOX_SCALE,
279
+ ]
280
+
281
+ def _call_endpoint(self, image: Image.Image) -> tuple[list[dict], str]:
282
+ """
283
+ Call Chandra via OpenAI API and return parsed predictions.
284
+
285
+ :param image: PIL Image to analyze
286
+ :return: Tuple of (parsed predictions list, raw response content)
287
+ :raises ProviderError: For API errors
288
+ """
289
+ img_base64 = self._image_to_base64(image)
290
+
291
+ try:
292
+ response = self._client.chat.completions.create(
293
+ model="chandra", # Model name configured in vLLM
294
+ messages=[
295
+ {
296
+ "role": "user",
297
+ "content": [
298
+ {"type": "text", "text": self._prompt},
299
+ {
300
+ "type": "image_url",
301
+ "image_url": {"url": f"data:image/png;base64,{img_base64}"},
302
+ },
303
+ ],
304
+ },
305
+ ],
306
+ max_tokens=8192,
307
+ temperature=0.1,
308
+ top_p=0.1,
309
+ )
310
+ except Exception as e:
311
+ error_msg = str(e).lower()
312
+ if "timeout" in error_msg or "connection" in error_msg:
313
+ raise ProviderTransientError(f"API call failed: {e}") from e
314
+ raise ProviderPermanentError(f"API call failed: {e}") from e
315
+
316
+ content = response.choices[0].message.content
317
+ if not content:
318
+ raise ProviderPermanentError("Empty response from model")
319
+
320
+ # Parse HTML to extract layout blocks
321
+ predictions = self._parse_html_layout_blocks(content)
322
+
323
+ return predictions, content
324
+
325
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
326
+ """
327
+ Run layout detection inference on an image.
328
+
329
+ :param pipeline: Pipeline specification
330
+ :param request: Inference request (source_file_path should be an image)
331
+ :return: Raw inference result
332
+ :raises ProviderError: For any provider-related failures
333
+ """
334
+ if request.product_type != ProductType.LAYOUT_DETECTION:
335
+ raise ProviderPermanentError(
336
+ f"{self.__class__.__name__} only supports LAYOUT_DETECTION product type, got {request.product_type}"
337
+ )
338
+
339
+ started_at = datetime.now()
340
+
341
+ # Load the image
342
+ try:
343
+ image: Image.Image = Image.open(request.source_file_path)
344
+ # Ensure image is in RGB mode
345
+ if image.mode not in ("RGB", "RGBA"):
346
+ image = image.convert("RGB")
347
+ except Exception as e:
348
+ raise ProviderPermanentError(f"Failed to load image: {e}") from e
349
+
350
+ # Get image dimensions
351
+ image_width, image_height = image.size
352
+
353
+ # Call the endpoint
354
+ predictions, raw_content = self._call_endpoint(image)
355
+
356
+ completed_at = datetime.now()
357
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
358
+
359
+ # Store in raw output for normalization
360
+ raw_output = {
361
+ "response": predictions,
362
+ "raw_content": raw_content,
363
+ "image_width": image_width,
364
+ "image_height": image_height,
365
+ }
366
+
367
+ return RawInferenceResult(
368
+ request=request,
369
+ pipeline=pipeline,
370
+ pipeline_name=pipeline.pipeline_name,
371
+ product_type=request.product_type,
372
+ raw_output=raw_output,
373
+ started_at=started_at,
374
+ completed_at=completed_at,
375
+ latency_in_ms=latency_ms,
376
+ )
377
+
378
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
379
+ """
380
+ Normalize raw inference result to produce LayoutOutput.
381
+
382
+ Converts normalized [0-1024] coordinates to pixel coordinates and
383
+ maps string labels to canonical labels.
384
+
385
+ :param raw_result: Raw inference result from run_inference()
386
+ :return: Inference result with both raw and normalized outputs
387
+ :raises ProviderError: For any normalization failures
388
+ """
389
+ if raw_result.product_type != ProductType.LAYOUT_DETECTION:
390
+ raise ProviderPermanentError(
391
+ f"{self.__class__.__name__} only supports LAYOUT_DETECTION product type, got {raw_result.product_type}"
392
+ )
393
+
394
+ # Get image dimensions
395
+ image_width = raw_result.raw_output.get("image_width", 0)
396
+ image_height = raw_result.raw_output.get("image_height", 0)
397
+
398
+ # Parse the response into predictions
399
+ response = raw_result.raw_output.get("response", [])
400
+
401
+ raw_predictions: list[LayoutPrediction] = []
402
+
403
+ for item in response:
404
+ label_str = item.get("label", "")
405
+ bbox_normalized = item.get("bbox", [0, 0, 0, 0])
406
+
407
+ # Convert string label to enum
408
+ label_enum = CHANDRA_STR_TO_LABEL.get(label_str)
409
+ if label_enum is None:
410
+ # Unknown label, skip
411
+ logger.warning(f"Unknown Chandra label: {label_str}")
412
+ continue
413
+
414
+ # Chandra doesn't output confidence scores, use 1.0
415
+ score = 1.0
416
+
417
+ # Convert normalized coords to pixel coords
418
+ bbox_pixels = self._normalize_bbox(bbox_normalized, image_width, image_height)
419
+
420
+ # Create raw prediction
421
+ raw_predictions.append(
422
+ LayoutPrediction(
423
+ bbox=bbox_pixels,
424
+ score=score,
425
+ label=str(int(label_enum)),
426
+ provider_metadata={"label_name": label_enum.name},
427
+ )
428
+ )
429
+
430
+ output = LayoutOutput(
431
+ task_type="layout_detection",
432
+ example_id=raw_result.request.example_id,
433
+ pipeline_name=raw_result.pipeline_name,
434
+ model=self.model_type,
435
+ image_width=max(int(image_width), 1),
436
+ image_height=max(int(image_height), 1),
437
+ predictions=raw_predictions,
438
+ )
439
+
440
+ return InferenceResult(
441
+ request=raw_result.request,
442
+ pipeline_name=raw_result.pipeline_name,
443
+ product_type=raw_result.product_type,
444
+ raw_output=raw_result.raw_output,
445
+ output=output,
446
+ started_at=raw_result.started_at,
447
+ completed_at=raw_result.completed_at,
448
+ latency_in_ms=raw_result.latency_in_ms,
449
+ )