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