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,436 @@
1
+ """Provider for Falcon-OCR server.
2
+
3
+ Falcon-OCR (tiiuae/Falcon-OCR) is a 300M early-fusion document OCR VLM
4
+ with built-in layout-aware OCR via `generate_with_layout`. The server
5
+ exposes a simple JSON endpoint at /predict that accepts a base64 image
6
+ and returns assembled markdown plus per-region layout metadata.
7
+ """
8
+
9
+ import asyncio
10
+ import base64
11
+ import io
12
+ import os
13
+ import re
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import aiohttp
19
+
20
+ from parse_bench.inference.providers.base import (
21
+ Provider,
22
+ ProviderConfigError,
23
+ ProviderPermanentError,
24
+ ProviderTransientError,
25
+ )
26
+ from parse_bench.inference.providers.registry import register_provider
27
+ from parse_bench.schemas.layout_ontology import CanonicalLabel
28
+ from parse_bench.schemas.parse_output import (
29
+ LayoutItemIR,
30
+ LayoutSegmentIR,
31
+ ParseLayoutPageIR,
32
+ ParseOutput,
33
+ )
34
+ from parse_bench.schemas.pipeline import PipelineSpec
35
+ from parse_bench.schemas.pipeline_io import (
36
+ InferenceRequest,
37
+ InferenceResult,
38
+ RawInferenceResult,
39
+ )
40
+ from parse_bench.schemas.product import ProductType
41
+
42
+ # Falcon-OCR uses PP-DocLayoutV3 internally, so the raw region labels match
43
+ # the PP-DocLayoutV3 label set.
44
+ _FALCONOCR_LABEL_TO_CANONICAL: dict[str, tuple[str, dict[str, str]]] = {
45
+ "doc_title": (CanonicalLabel.TITLE.value, {"title_level": "document"}),
46
+ "paragraph_title": (CanonicalLabel.SECTION_HEADER.value, {"title_level": "paragraph"}),
47
+ "text": (CanonicalLabel.TEXT.value, {}),
48
+ "vertical_text": (CanonicalLabel.TEXT.value, {"text_role": "vertical"}),
49
+ "number": (CanonicalLabel.TEXT.value, {"text_role": "page_number"}),
50
+ "abstract": (CanonicalLabel.TEXT.value, {"text_role": "abstract"}),
51
+ "content": (CanonicalLabel.TEXT.value, {"text_role": "body"}),
52
+ "reference": (CanonicalLabel.TEXT.value, {"text_role": "references"}),
53
+ "aside_text": (CanonicalLabel.TEXT.value, {"text_role": "sidebar"}),
54
+ "reference_content": (CanonicalLabel.TEXT.value, {"text_role": "references"}),
55
+ "formula_number": (CanonicalLabel.TEXT.value, {"text_role": "formula_number"}),
56
+ "header": (CanonicalLabel.PAGE_HEADER.value, {"furniture": "page-header"}),
57
+ "header_image": (CanonicalLabel.PAGE_HEADER.value, {"furniture": "page-header"}),
58
+ "footer": (CanonicalLabel.PAGE_FOOTER.value, {"furniture": "page-footer"}),
59
+ "footer_image": (CanonicalLabel.PAGE_FOOTER.value, {"furniture": "page-footer"}),
60
+ "footnote": (CanonicalLabel.FOOTNOTE.value, {}),
61
+ "vision_footnote": (CanonicalLabel.FOOTNOTE.value, {"footnote_of": "picture"}),
62
+ "image": (CanonicalLabel.PICTURE.value, {"picture_type": "image"}),
63
+ "chart": (CanonicalLabel.PICTURE.value, {"picture_type": "chart"}),
64
+ "seal": (CanonicalLabel.PICTURE.value, {"picture_type": "seal"}),
65
+ "figure_title": (CanonicalLabel.CAPTION.value, {"caption_of": "picture"}),
66
+ "table": (CanonicalLabel.TABLE.value, {}),
67
+ "formula": (CanonicalLabel.FORMULA.value, {}),
68
+ "display_formula": (CanonicalLabel.FORMULA.value, {"formula_style": "display"}),
69
+ "inline_formula": (CanonicalLabel.FORMULA.value, {"formula_style": "inline"}),
70
+ "algorithm": (CanonicalLabel.CODE.value, {}),
71
+ }
72
+
73
+
74
+ def _regions_to_layout_items(regions: list[dict[str, Any]]) -> list[LayoutItemIR]:
75
+ """Map Falcon-OCR `generate_with_layout` regions to LayoutItemIR.
76
+
77
+ Each region is `{category, bbox: [x0,y0,x1,y1], score, text}` where text
78
+ already has markdown formatting baked in by the model.
79
+ """
80
+ items: list[LayoutItemIR] = []
81
+ for region in regions:
82
+ label_raw = str(region.get("category", "")).strip().lower()
83
+ mapping = _FALCONOCR_LABEL_TO_CANONICAL.get(label_raw)
84
+ if mapping is None:
85
+ continue
86
+ canonical, _attrs = mapping
87
+
88
+ bbox = region.get("bbox")
89
+ if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
90
+ continue
91
+ try:
92
+ x1, y1, x2, y2 = (float(v) for v in bbox)
93
+ except (TypeError, ValueError):
94
+ continue
95
+
96
+ try:
97
+ score = float(region.get("score", 1.0))
98
+ except (TypeError, ValueError):
99
+ score = 1.0
100
+ score = max(0.0, min(1.0, score))
101
+
102
+ seg = LayoutSegmentIR(
103
+ x=x1,
104
+ y=y1,
105
+ w=max(0.0, x2 - x1),
106
+ h=max(0.0, y2 - y1),
107
+ confidence=score,
108
+ label=canonical,
109
+ )
110
+
111
+ text = region.get("text") or ""
112
+ item_md = ""
113
+ item_html = ""
114
+ item_value = ""
115
+ norm = canonical.strip().lower()
116
+ if text and norm != "picture":
117
+ if norm == "table":
118
+ item_html = str(text)
119
+ item_type = "table"
120
+ else:
121
+ item_md = str(text)
122
+ item_value = str(text)
123
+ item_type = "text"
124
+ elif norm == "picture":
125
+ item_type = "image"
126
+ else:
127
+ item_type = "text"
128
+
129
+ items.append(
130
+ LayoutItemIR(
131
+ type=item_type,
132
+ md=item_md,
133
+ html=item_html,
134
+ value=item_value,
135
+ bbox=seg,
136
+ layout_segments=[seg],
137
+ )
138
+ )
139
+ return items
140
+
141
+
142
+ @register_provider("falconocr")
143
+ class FalconOcrProvider(Provider):
144
+ """Provider for Falcon-OCR server.
145
+
146
+ Configuration options:
147
+ - server_url (str): server URL root (no /predict). Falls back to
148
+ the ``FALCONOCR_SERVER_URL`` environment variable.
149
+ - task (str, default="ocr"): "ocr" (layout-aware) or a generate()
150
+ category like "plain", "text", "table", "formula".
151
+ - timeout (int, default=600): Request timeout in seconds.
152
+ - dpi (int, default=200): DPI for PDF-to-image conversion.
153
+ - max_new_tokens (int, default=4096): Generation budget.
154
+ - temperature (float, default=0.0): Sampling temperature.
155
+ """
156
+
157
+ def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
158
+ super().__init__(provider_name, base_config)
159
+
160
+ server_url = self.base_config.get("server_url") or os.getenv("FALCONOCR_SERVER_URL")
161
+ if not server_url:
162
+ raise ProviderConfigError(
163
+ "FalconOCR provider requires 'server_url' in config or FALCONOCR_SERVER_URL in the environment."
164
+ )
165
+ self._server_url: str = str(server_url).rstrip("/")
166
+ self._task: str = str(self.base_config.get("task", "ocr"))
167
+ self._timeout = int(self.base_config.get("timeout", 600))
168
+ self._dpi = int(self.base_config.get("dpi", 200))
169
+ self._max_new_tokens = int(self.base_config.get("max_new_tokens", 4096))
170
+ self._temperature = float(self.base_config.get("temperature", 0.0))
171
+
172
+ def _pdf_to_images(self, pdf_path: Path) -> list[bytes]:
173
+ """Render every PDF page to PNG bytes in source order."""
174
+ try:
175
+ from pdf2image import convert_from_path
176
+
177
+ images = convert_from_path(pdf_path, dpi=self._dpi)
178
+ if not images:
179
+ raise ProviderPermanentError(f"No pages found in PDF: {pdf_path}")
180
+ encoded: list[bytes] = []
181
+ for image in images:
182
+ buf = io.BytesIO()
183
+ image.save(buf, format="PNG")
184
+ encoded.append(buf.getvalue())
185
+ return encoded
186
+ except ImportError as e:
187
+ raise ProviderPermanentError("pdf2image is required. Install with: pip install pdf2image") from e
188
+ except Exception as e:
189
+ if "pdf2image" in str(e).lower():
190
+ raise
191
+ raise ProviderPermanentError(f"Error converting PDF to image: {e}") from e
192
+
193
+ def _read_image(self, file_path: Path) -> bytes:
194
+ try:
195
+ return file_path.read_bytes()
196
+ except Exception as e:
197
+ raise ProviderPermanentError(f"Error reading image file: {e}") from e
198
+
199
+ async def _call_api(self, session: aiohttp.ClientSession, image_b64: str) -> dict[str, Any]:
200
+ api_url = f"{self._server_url}/predict"
201
+ payload = {
202
+ "image_base64": image_b64,
203
+ "task": self._task,
204
+ "max_new_tokens": self._max_new_tokens,
205
+ "temperature": self._temperature,
206
+ }
207
+ async with session.post(
208
+ api_url,
209
+ json=payload,
210
+ headers={"Content-Type": "application/json"},
211
+ timeout=aiohttp.ClientTimeout(total=self._timeout),
212
+ ) as resp:
213
+ if resp.status != 200:
214
+ error_text = await resp.text()
215
+ if resp.status in (408, 502, 503, 504):
216
+ raise ProviderTransientError(f"HTTP {resp.status}: {error_text[:200]}")
217
+ raise ProviderPermanentError(f"HTTP {resp.status}: {error_text[:200]}")
218
+ result: dict[str, Any] = await resp.json()
219
+
220
+ if result.get("status") != "success":
221
+ raise ProviderPermanentError(
222
+ f"Server returned status={result.get('status')}: {str(result.get('error'))[:200]}"
223
+ )
224
+ return result
225
+
226
+ async def _run_inference_async(self, image_bytes: bytes) -> dict[str, Any]:
227
+ image_b64 = base64.b64encode(image_bytes).decode()
228
+
229
+ async with aiohttp.ClientSession() as session:
230
+ response = await self._call_api(session, image_b64)
231
+
232
+ return {
233
+ "markdown": response.get("markdown", ""),
234
+ "regions": response.get("regions", []),
235
+ "image_width": response.get("image_width"),
236
+ "image_height": response.get("image_height"),
237
+ "_task_used": response.get("task"),
238
+ "_config": {
239
+ "server_url": self._server_url,
240
+ "task": self._task,
241
+ "dpi": self._dpi,
242
+ "max_new_tokens": self._max_new_tokens,
243
+ "temperature": self._temperature,
244
+ },
245
+ }
246
+
247
+ async def _run_inference_pages_async(self, pages: list[bytes]) -> dict[str, Any]:
248
+ """Run each input page in order, retaining the legacy one-page shape."""
249
+ results = [await self._run_inference_async(page) for page in pages]
250
+ first = results[0]
251
+ if len(results) == 1:
252
+ return first
253
+ merged = dict(first)
254
+ merged["page_results"] = results
255
+ return merged
256
+
257
+ def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
258
+ if request.product_type != ProductType.PARSE:
259
+ raise ProviderPermanentError(
260
+ f"FalconOcrProvider only supports PARSE product type, got {request.product_type}"
261
+ )
262
+
263
+ started_at = datetime.now()
264
+
265
+ file_path = Path(request.source_file_path)
266
+ if not file_path.exists():
267
+ raise ProviderPermanentError(f"Source file not found: {file_path}")
268
+
269
+ suffix = file_path.suffix.lower()
270
+ if suffix == ".pdf":
271
+ page_images = self._pdf_to_images(file_path)
272
+ elif suffix in (".png", ".jpg", ".jpeg", ".webp", ".tiff", ".bmp"):
273
+ page_images = [self._read_image(file_path)]
274
+ else:
275
+ raise ProviderPermanentError(
276
+ f"Unsupported file type: {suffix}. Supported: .pdf, .png, .jpg, .jpeg, .webp, .tiff, .bmp"
277
+ )
278
+
279
+ try:
280
+ raw_output = asyncio.run(self._run_inference_pages_async(page_images))
281
+ completed_at = datetime.now()
282
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
283
+
284
+ return RawInferenceResult(
285
+ request=request,
286
+ pipeline=pipeline,
287
+ pipeline_name=pipeline.pipeline_name,
288
+ product_type=request.product_type,
289
+ raw_output=raw_output,
290
+ started_at=started_at,
291
+ completed_at=completed_at,
292
+ latency_in_ms=latency_ms,
293
+ )
294
+
295
+ except (ProviderPermanentError, ProviderTransientError):
296
+ raise
297
+
298
+ except Exception as e:
299
+ completed_at = datetime.now()
300
+ latency_ms = int((completed_at - started_at).total_seconds() * 1000)
301
+ error_msg = str(e)
302
+ if isinstance(e, asyncio.TimeoutError):
303
+ error_msg = f"Request timed out after {self._timeout} seconds"
304
+ return RawInferenceResult(
305
+ request=request,
306
+ pipeline=pipeline,
307
+ pipeline_name=pipeline.pipeline_name,
308
+ product_type=request.product_type,
309
+ raw_output={
310
+ "markdown": "",
311
+ "_error": error_msg,
312
+ "_error_type": type(e).__name__,
313
+ "_config": {"server_url": self._server_url, "dpi": self._dpi},
314
+ },
315
+ started_at=started_at,
316
+ completed_at=completed_at,
317
+ latency_in_ms=latency_ms,
318
+ )
319
+
320
+ @staticmethod
321
+ def _sanitize_html_attributes(markdown: str) -> str:
322
+ """Quote unquoted HTML attributes for XML-based metric parsers."""
323
+
324
+ def _quote_attrs(match: re.Match) -> str:
325
+ tag_text = match.group(0)
326
+ tag_text = re.sub(
327
+ r'(\w+)=([^\s"\'<>=]+)',
328
+ r'\1="\2"',
329
+ tag_text,
330
+ )
331
+ return tag_text
332
+
333
+ return re.sub(r"<[^>]+>", _quote_attrs, markdown)
334
+
335
+ @staticmethod
336
+ def _convert_md_tables_to_html(content: str) -> str:
337
+ """Convert markdown pipe tables to HTML <table> elements.
338
+
339
+ Falcon-OCR's table category emits HTML <table> directly, but mixed
340
+ outputs (e.g. plain task on a doc with tables) may include pipe
341
+ tables. GriTS/TEDS metrics only parse HTML, so we convert.
342
+ """
343
+ import markdown2
344
+
345
+ lines = content.split("\n")
346
+ result_parts: list[str] = []
347
+ table_lines: list[str] = []
348
+ in_table = False
349
+
350
+ for line in lines:
351
+ is_table_line = "|" in line and line.strip().startswith("|")
352
+ if is_table_line:
353
+ if not in_table:
354
+ in_table = True
355
+ table_lines = [line]
356
+ else:
357
+ table_lines.append(line)
358
+ else:
359
+ if in_table:
360
+ if len(table_lines) >= 2:
361
+ table_md = "\n".join(table_lines)
362
+ html = markdown2.markdown(table_md, extras=["tables"]).strip()
363
+ if "<table>" in html.lower():
364
+ result_parts.append(html)
365
+ else:
366
+ result_parts.extend(table_lines)
367
+ else:
368
+ result_parts.extend(table_lines)
369
+ table_lines = []
370
+ in_table = False
371
+ result_parts.append(line)
372
+
373
+ if in_table and len(table_lines) >= 2:
374
+ table_md = "\n".join(table_lines)
375
+ html = markdown2.markdown(table_md, extras=["tables"]).strip()
376
+ if "<table>" in html.lower():
377
+ result_parts.append(html)
378
+ else:
379
+ result_parts.extend(table_lines)
380
+ elif in_table:
381
+ result_parts.extend(table_lines)
382
+
383
+ return "\n".join(result_parts)
384
+
385
+ def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
386
+ if raw_result.product_type != ProductType.PARSE:
387
+ raise ProviderPermanentError(
388
+ f"FalconOcrProvider only supports PARSE product type, got {raw_result.product_type}"
389
+ )
390
+
391
+ page_results = raw_result.raw_output.get("page_results")
392
+ if not isinstance(page_results, list) or not page_results:
393
+ page_results = [raw_result.raw_output]
394
+
395
+ page_markdowns: list[str] = []
396
+ layout_pages: list[ParseLayoutPageIR] = []
397
+ for page_number, page_raw in enumerate(page_results, start=1):
398
+ markdown = page_raw.get("markdown", "")
399
+ if markdown:
400
+ markdown = self._convert_md_tables_to_html(markdown)
401
+ markdown = self._sanitize_html_attributes(markdown)
402
+ page_markdowns.append(markdown)
403
+
404
+ image_width = max(int(page_raw.get("image_width") or 1), 1)
405
+ image_height = max(int(page_raw.get("image_height") or 1), 1)
406
+ items = _regions_to_layout_items(page_raw.get("regions") or [])
407
+ if items:
408
+ layout_pages.append(
409
+ ParseLayoutPageIR(
410
+ page_number=page_number,
411
+ width=float(image_width),
412
+ height=float(image_height),
413
+ items=items,
414
+ )
415
+ )
416
+ markdown = "\n\n".join(page_markdowns)
417
+
418
+ output = ParseOutput(
419
+ task_type="parse",
420
+ example_id=raw_result.request.example_id,
421
+ pipeline_name=raw_result.pipeline_name,
422
+ pages=[],
423
+ markdown=markdown,
424
+ layout_pages=layout_pages,
425
+ )
426
+
427
+ return InferenceResult(
428
+ request=raw_result.request,
429
+ pipeline_name=raw_result.pipeline_name,
430
+ product_type=raw_result.product_type,
431
+ raw_output=raw_result.raw_output,
432
+ output=output,
433
+ started_at=raw_result.started_at,
434
+ completed_at=raw_result.completed_at,
435
+ latency_in_ms=raw_result.latency_in_ms,
436
+ )