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,382 @@
|
|
|
1
|
+
"""Provider for DeepSeek-OCR-2 Modal server.
|
|
2
|
+
|
|
3
|
+
DeepSeek-OCR-2 (deepseek-ai/DeepSeek-OCR-2) is a MoE vision-language model
|
|
4
|
+
that handles layout detection + OCR in a single pass via the <|grounding|> token.
|
|
5
|
+
|
|
6
|
+
API format: POST /predict with {"image_base64": "..."} → {"markdown": "...", "status": "success"}
|
|
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.parse._layout_utils import build_layout_pages
|
|
27
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
28
|
+
from parse_bench.schemas.parse_output import ParseOutput
|
|
29
|
+
from parse_bench.schemas.pipeline import PipelineSpec
|
|
30
|
+
from parse_bench.schemas.pipeline_io import (
|
|
31
|
+
InferenceRequest,
|
|
32
|
+
InferenceResult,
|
|
33
|
+
RawInferenceResult,
|
|
34
|
+
)
|
|
35
|
+
from parse_bench.schemas.product import ProductType
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@register_provider("deepseekocr2")
|
|
39
|
+
class DeepSeekOCR2Provider(Provider):
|
|
40
|
+
"""
|
|
41
|
+
Provider for DeepSeek-OCR-2 Modal server.
|
|
42
|
+
|
|
43
|
+
Configuration options:
|
|
44
|
+
- server_url (str, required): Modal server predict endpoint URL
|
|
45
|
+
- timeout (int, default=600): Request timeout in seconds
|
|
46
|
+
- dpi (int, default=150): DPI for PDF to image conversion
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
|
|
50
|
+
super().__init__(provider_name, base_config)
|
|
51
|
+
|
|
52
|
+
server_url = self.base_config.get("server_url") or os.getenv("DEEPSEEKOCR2_SERVER_URL")
|
|
53
|
+
if not server_url:
|
|
54
|
+
raise ProviderConfigError("DeepSeekOCR2 provider requires 'server_url' in config.")
|
|
55
|
+
self._server_url: str = server_url
|
|
56
|
+
|
|
57
|
+
self._timeout = self.base_config.get("timeout", 600)
|
|
58
|
+
self._dpi = self.base_config.get("dpi", 150)
|
|
59
|
+
|
|
60
|
+
def _pdf_to_images(self, pdf_path: Path) -> list[bytes]:
|
|
61
|
+
"""Render every PDF page to PNG bytes in source order."""
|
|
62
|
+
try:
|
|
63
|
+
from pdf2image import convert_from_path
|
|
64
|
+
|
|
65
|
+
images = convert_from_path(pdf_path, dpi=self._dpi)
|
|
66
|
+
if not images:
|
|
67
|
+
raise ProviderPermanentError(f"No pages found in PDF: {pdf_path}")
|
|
68
|
+
encoded: list[bytes] = []
|
|
69
|
+
for image in images:
|
|
70
|
+
buf = io.BytesIO()
|
|
71
|
+
image.save(buf, format="PNG")
|
|
72
|
+
encoded.append(buf.getvalue())
|
|
73
|
+
return encoded
|
|
74
|
+
except ImportError as e:
|
|
75
|
+
raise ProviderPermanentError("pdf2image is required.") from e
|
|
76
|
+
except Exception as e:
|
|
77
|
+
if "pdf2image" in str(e).lower():
|
|
78
|
+
raise
|
|
79
|
+
raise ProviderPermanentError(f"Error converting PDF to image: {e}") from e
|
|
80
|
+
|
|
81
|
+
def _read_image(self, file_path: Path) -> bytes:
|
|
82
|
+
try:
|
|
83
|
+
return file_path.read_bytes()
|
|
84
|
+
except Exception as e:
|
|
85
|
+
raise ProviderPermanentError(f"Error reading image file: {e}") from e
|
|
86
|
+
|
|
87
|
+
async def _call_api(self, session: aiohttp.ClientSession, image_b64: str) -> dict[str, Any]:
|
|
88
|
+
api_url = self._server_url.rstrip("/")
|
|
89
|
+
|
|
90
|
+
payload: dict[str, str] = {"image_base64": image_b64}
|
|
91
|
+
|
|
92
|
+
async with session.post(
|
|
93
|
+
api_url,
|
|
94
|
+
json=payload,
|
|
95
|
+
headers={"Content-Type": "application/json"},
|
|
96
|
+
timeout=aiohttp.ClientTimeout(total=self._timeout),
|
|
97
|
+
) as resp:
|
|
98
|
+
if resp.status != 200:
|
|
99
|
+
error_text = await resp.text()
|
|
100
|
+
if resp.status in (408, 502, 503, 504):
|
|
101
|
+
raise ProviderTransientError(f"HTTP {resp.status}: {error_text[:200]}")
|
|
102
|
+
raise ProviderPermanentError(f"HTTP {resp.status}: {error_text[:200]}")
|
|
103
|
+
|
|
104
|
+
result: dict[str, Any] = await resp.json()
|
|
105
|
+
if result.get("status") == "error":
|
|
106
|
+
raise ProviderPermanentError(result.get("error", "Unknown error from API"))
|
|
107
|
+
|
|
108
|
+
content: str = result.get("markdown", "")
|
|
109
|
+
if not content:
|
|
110
|
+
raise ProviderPermanentError("Empty markdown response from API")
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
async def _run_inference_async(self, image_bytes: bytes) -> dict[str, Any]:
|
|
114
|
+
image_b64 = base64.b64encode(image_bytes).decode()
|
|
115
|
+
|
|
116
|
+
async with aiohttp.ClientSession() as session:
|
|
117
|
+
result = await self._call_api(session, image_b64)
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
"markdown": result.get("markdown", ""),
|
|
121
|
+
"grounding_items": result.get("grounding_items", []),
|
|
122
|
+
"image_width": result.get("image_width"),
|
|
123
|
+
"image_height": result.get("image_height"),
|
|
124
|
+
"_config": {
|
|
125
|
+
"server_url": self._server_url,
|
|
126
|
+
"dpi": self._dpi,
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async def _run_inference_pages_async(self, pages: list[bytes]) -> dict[str, Any]:
|
|
131
|
+
"""Run each input page in order, retaining the legacy one-page shape."""
|
|
132
|
+
results = [await self._run_inference_async(page) for page in pages]
|
|
133
|
+
first = results[0]
|
|
134
|
+
if len(results) == 1:
|
|
135
|
+
return first
|
|
136
|
+
merged = dict(first)
|
|
137
|
+
merged["page_results"] = results
|
|
138
|
+
return merged
|
|
139
|
+
|
|
140
|
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
|
141
|
+
if request.product_type != ProductType.PARSE:
|
|
142
|
+
raise ProviderPermanentError(
|
|
143
|
+
f"DeepSeekOCR2Provider only supports PARSE product type, got {request.product_type}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
started_at = datetime.now()
|
|
147
|
+
|
|
148
|
+
file_path = Path(request.source_file_path)
|
|
149
|
+
if not file_path.exists():
|
|
150
|
+
raise ProviderPermanentError(f"Source file not found: {file_path}")
|
|
151
|
+
|
|
152
|
+
suffix = file_path.suffix.lower()
|
|
153
|
+
if suffix == ".pdf":
|
|
154
|
+
page_images = self._pdf_to_images(file_path)
|
|
155
|
+
elif suffix in (".png", ".jpg", ".jpeg", ".webp", ".tiff", ".bmp"):
|
|
156
|
+
page_images = [self._read_image(file_path)]
|
|
157
|
+
else:
|
|
158
|
+
raise ProviderPermanentError(
|
|
159
|
+
f"Unsupported file type: {suffix}. Supported: .pdf, .png, .jpg, .jpeg, .webp, .tiff, .bmp"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
raw_output = asyncio.run(self._run_inference_pages_async(page_images))
|
|
164
|
+
completed_at = datetime.now()
|
|
165
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
166
|
+
|
|
167
|
+
return RawInferenceResult(
|
|
168
|
+
request=request,
|
|
169
|
+
pipeline=pipeline,
|
|
170
|
+
pipeline_name=pipeline.pipeline_name,
|
|
171
|
+
product_type=request.product_type,
|
|
172
|
+
raw_output=raw_output,
|
|
173
|
+
started_at=started_at,
|
|
174
|
+
completed_at=completed_at,
|
|
175
|
+
latency_in_ms=latency_ms,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
except (ProviderPermanentError, ProviderTransientError):
|
|
179
|
+
raise
|
|
180
|
+
|
|
181
|
+
except Exception as e:
|
|
182
|
+
completed_at = datetime.now()
|
|
183
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
184
|
+
|
|
185
|
+
error_msg = str(e)
|
|
186
|
+
if isinstance(e, asyncio.TimeoutError):
|
|
187
|
+
error_msg = f"Request timed out after {self._timeout} seconds"
|
|
188
|
+
|
|
189
|
+
return RawInferenceResult(
|
|
190
|
+
request=request,
|
|
191
|
+
pipeline=pipeline,
|
|
192
|
+
pipeline_name=pipeline.pipeline_name,
|
|
193
|
+
product_type=request.product_type,
|
|
194
|
+
raw_output={
|
|
195
|
+
"markdown": "",
|
|
196
|
+
"_error": error_msg,
|
|
197
|
+
"_error_type": type(e).__name__,
|
|
198
|
+
"_config": {
|
|
199
|
+
"server_url": self._server_url,
|
|
200
|
+
"dpi": self._dpi,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
started_at=started_at,
|
|
204
|
+
completed_at=completed_at,
|
|
205
|
+
latency_in_ms=latency_ms,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _close_unclosed_table_tags(content: str) -> str:
|
|
210
|
+
"""Auto-close unclosed HTML table tags from truncated model output."""
|
|
211
|
+
opens = content.count("<table>")
|
|
212
|
+
closes = content.count("</table>")
|
|
213
|
+
if opens > closes:
|
|
214
|
+
# Close any unclosed row/cell tags, then close the table
|
|
215
|
+
if content.rstrip().endswith(">"):
|
|
216
|
+
pass # last tag is already closed
|
|
217
|
+
else:
|
|
218
|
+
# Truncated mid-cell — close the cell and row
|
|
219
|
+
content += "</td></tr>"
|
|
220
|
+
content += "</table>" * (opens - closes)
|
|
221
|
+
return content
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _promote_first_row_to_thead(content: str) -> str:
|
|
225
|
+
"""Wrap the first <tr> of each <table> in <thead> and convert <td> to <th>.
|
|
226
|
+
|
|
227
|
+
The grounding model outputs all cells as <td>, never using <th>/<thead>.
|
|
228
|
+
This heuristic promotes the first row to a header row, matching how
|
|
229
|
+
markdown2 handles pipe tables and improving header metric scores.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def _promote_table(match: re.Match[str]) -> str:
|
|
233
|
+
table_html: str = match.group(0)
|
|
234
|
+
# Find the first <tr>...</tr>
|
|
235
|
+
first_tr = re.search(r"<tr>(.*?)</tr>", table_html, re.DOTALL)
|
|
236
|
+
if not first_tr:
|
|
237
|
+
return table_html
|
|
238
|
+
first_tr_full: str = first_tr.group(0)
|
|
239
|
+
first_tr_inner: str = first_tr.group(1)
|
|
240
|
+
# Convert <td> to <th> in the first row
|
|
241
|
+
header_inner = first_tr_inner.replace("<td>", "<th>").replace("</td>", "</th>")
|
|
242
|
+
# Also handle <td with attributes
|
|
243
|
+
header_inner = re.sub(r"<td(\s)", r"<th\1", header_inner)
|
|
244
|
+
header_inner = re.sub(r"</td>", "</th>", header_inner)
|
|
245
|
+
thead = f"<thead><tr>{header_inner}</tr></thead>"
|
|
246
|
+
# Replace first <tr> with <thead> block
|
|
247
|
+
table_html = table_html.replace(first_tr_full, thead, 1)
|
|
248
|
+
return table_html
|
|
249
|
+
|
|
250
|
+
return re.sub(r"<table>.*?</table>", _promote_table, content, flags=re.DOTALL)
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _sanitize_html_attributes(markdown: str) -> str:
|
|
254
|
+
"""Quote unquoted HTML attributes for XML-based metric parsers."""
|
|
255
|
+
|
|
256
|
+
def _quote_attrs(match: re.Match) -> str:
|
|
257
|
+
tag_text = match.group(0)
|
|
258
|
+
tag_text = re.sub(
|
|
259
|
+
r'(\w+)=([^\s"\'<>=]+)',
|
|
260
|
+
r'\1="\2"',
|
|
261
|
+
tag_text,
|
|
262
|
+
)
|
|
263
|
+
return tag_text
|
|
264
|
+
|
|
265
|
+
return re.sub(r"<[^>]+>", _quote_attrs, markdown)
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _convert_md_tables_to_html(content: str) -> str:
|
|
269
|
+
"""Convert markdown pipe tables to HTML for GriTS/TEDS metrics."""
|
|
270
|
+
import markdown2
|
|
271
|
+
|
|
272
|
+
lines = content.split("\n")
|
|
273
|
+
result_parts: list[str] = []
|
|
274
|
+
table_lines: list[str] = []
|
|
275
|
+
in_table = False
|
|
276
|
+
|
|
277
|
+
for line in lines:
|
|
278
|
+
is_table_line = "|" in line and line.strip().startswith("|")
|
|
279
|
+
if is_table_line:
|
|
280
|
+
if not in_table:
|
|
281
|
+
in_table = True
|
|
282
|
+
table_lines = [line]
|
|
283
|
+
else:
|
|
284
|
+
table_lines.append(line)
|
|
285
|
+
else:
|
|
286
|
+
if in_table:
|
|
287
|
+
if len(table_lines) >= 2:
|
|
288
|
+
table_md = "\n".join(table_lines)
|
|
289
|
+
html = markdown2.markdown(table_md, extras=["tables"]).strip()
|
|
290
|
+
if "<table>" in html.lower():
|
|
291
|
+
result_parts.append(html)
|
|
292
|
+
else:
|
|
293
|
+
result_parts.extend(table_lines)
|
|
294
|
+
else:
|
|
295
|
+
result_parts.extend(table_lines)
|
|
296
|
+
table_lines = []
|
|
297
|
+
in_table = False
|
|
298
|
+
result_parts.append(line)
|
|
299
|
+
|
|
300
|
+
if in_table and len(table_lines) >= 2:
|
|
301
|
+
table_md = "\n".join(table_lines)
|
|
302
|
+
html = markdown2.markdown(table_md, extras=["tables"]).strip()
|
|
303
|
+
if "<table>" in html.lower():
|
|
304
|
+
result_parts.append(html)
|
|
305
|
+
else:
|
|
306
|
+
result_parts.extend(table_lines)
|
|
307
|
+
elif in_table:
|
|
308
|
+
result_parts.extend(table_lines)
|
|
309
|
+
|
|
310
|
+
return "\n".join(result_parts)
|
|
311
|
+
|
|
312
|
+
# Grounding label aliases -> the names the shared _layout_utils.LABEL_MAP
|
|
313
|
+
# understands (title/table/caption/footnote already match; these three differ).
|
|
314
|
+
_LABEL_ALIASES: dict[str, str] = {
|
|
315
|
+
"header": "page-header",
|
|
316
|
+
"footer": "page-footer",
|
|
317
|
+
"image": "picture",
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
321
|
+
if raw_result.product_type != ProductType.PARSE:
|
|
322
|
+
raise ProviderPermanentError(
|
|
323
|
+
f"DeepSeekOCR2Provider only supports PARSE product type, got {raw_result.product_type}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
page_results = raw_result.raw_output.get("page_results")
|
|
327
|
+
if not isinstance(page_results, list) or not page_results:
|
|
328
|
+
page_results = [raw_result.raw_output]
|
|
329
|
+
|
|
330
|
+
page_markdowns: list[str] = []
|
|
331
|
+
layout_pages = []
|
|
332
|
+
for page_number, page_raw in enumerate(page_results, start=1):
|
|
333
|
+
markdown = page_raw.get("markdown", "")
|
|
334
|
+
if markdown:
|
|
335
|
+
# Auto-close unclosed HTML table tags (model truncates at max_tokens)
|
|
336
|
+
markdown = self._close_unclosed_table_tags(markdown)
|
|
337
|
+
markdown = self._convert_md_tables_to_html(markdown)
|
|
338
|
+
# Promote first row to <thead>/<th> (model outputs all <td>)
|
|
339
|
+
markdown = self._promote_first_row_to_thead(markdown)
|
|
340
|
+
markdown = self._sanitize_html_attributes(markdown)
|
|
341
|
+
page_markdowns.append(markdown)
|
|
342
|
+
|
|
343
|
+
# Build each page's layout from its grounding items. Grounding bboxes
|
|
344
|
+
# are [x1,y1,x2,y2] on a 0-999 grid; aliases map labels onto LABEL_MAP.
|
|
345
|
+
grounding_items = page_raw.get("grounding_items", [])
|
|
346
|
+
layout_items = [
|
|
347
|
+
{
|
|
348
|
+
"label": self._LABEL_ALIASES.get(str(gi.get("label", "")).lower(), gi.get("label", "")),
|
|
349
|
+
"bbox": gi.get("bbox", []),
|
|
350
|
+
}
|
|
351
|
+
for gi in grounding_items
|
|
352
|
+
]
|
|
353
|
+
layout_pages.extend(
|
|
354
|
+
build_layout_pages(
|
|
355
|
+
items=layout_items,
|
|
356
|
+
image_width=page_raw.get("image_width", 0),
|
|
357
|
+
image_height=page_raw.get("image_height", 0),
|
|
358
|
+
markdown=markdown,
|
|
359
|
+
page_number=page_number,
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
markdown = "\n\n".join(page_markdowns)
|
|
363
|
+
|
|
364
|
+
output = ParseOutput(
|
|
365
|
+
task_type="parse",
|
|
366
|
+
example_id=raw_result.request.example_id,
|
|
367
|
+
pipeline_name=raw_result.pipeline_name,
|
|
368
|
+
pages=[],
|
|
369
|
+
markdown=markdown,
|
|
370
|
+
layout_pages=layout_pages,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return InferenceResult(
|
|
374
|
+
request=raw_result.request,
|
|
375
|
+
pipeline_name=raw_result.pipeline_name,
|
|
376
|
+
product_type=raw_result.product_type,
|
|
377
|
+
raw_output=raw_result.raw_output,
|
|
378
|
+
output=output,
|
|
379
|
+
started_at=raw_result.started_at,
|
|
380
|
+
completed_at=raw_result.completed_at,
|
|
381
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
382
|
+
)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Provider for Docling parse via a custom HTTP endpoint."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from docling_core.types.doc.document import DoclingDocument
|
|
11
|
+
|
|
12
|
+
from parse_bench.inference.providers.base import (
|
|
13
|
+
Provider,
|
|
14
|
+
ProviderConfigError,
|
|
15
|
+
ProviderPermanentError,
|
|
16
|
+
ProviderRateLimitError,
|
|
17
|
+
ProviderTransientError,
|
|
18
|
+
)
|
|
19
|
+
from parse_bench.inference.providers.parse._docling_common import _build_docling_layout_pages
|
|
20
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
21
|
+
from parse_bench.schemas.parse_output import (
|
|
22
|
+
PageIR,
|
|
23
|
+
ParseLayoutPageIR,
|
|
24
|
+
ParseOutput,
|
|
25
|
+
)
|
|
26
|
+
from parse_bench.schemas.pipeline import PipelineSpec
|
|
27
|
+
from parse_bench.schemas.pipeline_io import (
|
|
28
|
+
InferenceRequest,
|
|
29
|
+
InferenceResult,
|
|
30
|
+
RawInferenceResult,
|
|
31
|
+
)
|
|
32
|
+
from parse_bench.schemas.product import ProductType
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@register_provider("docling_parse")
|
|
36
|
+
class DoclingParseProvider(Provider):
|
|
37
|
+
"""
|
|
38
|
+
Provider for Docling PDF parsing via a custom HTTP endpoint.
|
|
39
|
+
|
|
40
|
+
This provider sends PDFs to a Docling endpoint and returns markdown
|
|
41
|
+
with tables formatted as HTML.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
provider_name: str,
|
|
47
|
+
base_config: dict[str, Any] | None = None,
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Initialize the Docling parse provider.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
provider_name: Name of the provider
|
|
54
|
+
base_config: Optional configuration with:
|
|
55
|
+
- `api_key`: Optional bearer token for the endpoint
|
|
56
|
+
- `hf_token`: Deprecated alias for `api_key`
|
|
57
|
+
- `endpoint_url`: Endpoint URL (required)
|
|
58
|
+
- `timeout`: Request timeout in seconds (default: 120)
|
|
59
|
+
"""
|
|
60
|
+
super().__init__(provider_name, base_config)
|
|
61
|
+
|
|
62
|
+
# Optional bearer token; keep `hf_token` / `HF_TOKEN` as a deprecated
|
|
63
|
+
# fallback for backwards compatibility with the previous HF deployment.
|
|
64
|
+
self._api_key = (
|
|
65
|
+
self.base_config.get("api_key")
|
|
66
|
+
or self.base_config.get("hf_token")
|
|
67
|
+
or os.getenv("DOCLING_PARSE_API_KEY")
|
|
68
|
+
or os.getenv("HF_TOKEN")
|
|
69
|
+
or ""
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Get endpoint URL (from config or env var)
|
|
73
|
+
self._endpoint_url = self.base_config.get("endpoint_url") or os.getenv("DOCLING_PARSE_ENDPOINT_URL")
|
|
74
|
+
if not self._endpoint_url:
|
|
75
|
+
raise ProviderConfigError(
|
|
76
|
+
"Docling endpoint URL is required. "
|
|
77
|
+
"Set DOCLING_PARSE_ENDPOINT_URL environment variable or "
|
|
78
|
+
"pass endpoint_url in pipeline config."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Get timeout (default 120 seconds - PDF processing can be slow)
|
|
82
|
+
self._timeout = self.base_config.get("timeout", 120)
|
|
83
|
+
|
|
84
|
+
def _call_endpoint(self, pdf_bytes: bytes) -> dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
Call the Docling endpoint with PDF bytes.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
pdf_bytes: Raw PDF file bytes
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Raw JSON response from endpoint
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ProviderError: For any API errors
|
|
96
|
+
"""
|
|
97
|
+
headers = {"Content-Type": "application/json"}
|
|
98
|
+
if self._api_key:
|
|
99
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
100
|
+
|
|
101
|
+
# Encode PDF as base64
|
|
102
|
+
pdf_base64 = base64.b64encode(pdf_bytes).decode("utf-8")
|
|
103
|
+
|
|
104
|
+
payload = {
|
|
105
|
+
"inputs": {
|
|
106
|
+
"pdf_base64": pdf_base64,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
response = requests.post(
|
|
112
|
+
self._endpoint_url,
|
|
113
|
+
headers=headers,
|
|
114
|
+
json=payload,
|
|
115
|
+
timeout=self._timeout,
|
|
116
|
+
)
|
|
117
|
+
response.raise_for_status()
|
|
118
|
+
result_json = response.json()
|
|
119
|
+
if isinstance(result_json, list):
|
|
120
|
+
if not result_json:
|
|
121
|
+
raise ProviderPermanentError("Endpoint returned an empty list response.")
|
|
122
|
+
first_result = result_json[0]
|
|
123
|
+
if not isinstance(first_result, dict):
|
|
124
|
+
raise ProviderPermanentError("Endpoint returned a list response with a non-dict payload.")
|
|
125
|
+
result = first_result
|
|
126
|
+
elif isinstance(result_json, dict):
|
|
127
|
+
result = result_json
|
|
128
|
+
else:
|
|
129
|
+
raise ProviderPermanentError(
|
|
130
|
+
f"Endpoint returned unsupported response type: {type(result_json).__name__}"
|
|
131
|
+
)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
except requests.exceptions.Timeout as e:
|
|
135
|
+
raise ProviderTransientError(f"Request timed out: {e}") from e
|
|
136
|
+
except requests.exceptions.ConnectionError as e:
|
|
137
|
+
raise ProviderTransientError(f"Connection error: {e}") from e
|
|
138
|
+
except requests.exceptions.HTTPError as e:
|
|
139
|
+
status_code = e.response.status_code if e.response else None
|
|
140
|
+
if status_code == 429:
|
|
141
|
+
raise ProviderRateLimitError(f"Rate limit exceeded: {e}") from e
|
|
142
|
+
elif status_code and 500 <= status_code < 600:
|
|
143
|
+
raise ProviderTransientError(f"Server error ({status_code}): {e}") from e
|
|
144
|
+
elif status_code and 400 <= status_code < 500:
|
|
145
|
+
raise ProviderPermanentError(f"Client error ({status_code}): {e}") from e
|
|
146
|
+
else:
|
|
147
|
+
raise ProviderPermanentError(f"HTTP error: {e}") from e
|
|
148
|
+
except (ProviderPermanentError, ProviderTransientError, ProviderRateLimitError):
|
|
149
|
+
raise
|
|
150
|
+
except Exception as e:
|
|
151
|
+
raise ProviderPermanentError(f"Unexpected error calling endpoint: {e}") from e
|
|
152
|
+
|
|
153
|
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
|
154
|
+
"""
|
|
155
|
+
Run inference and return raw results.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
pipeline: Pipeline specification
|
|
159
|
+
request: Inference request
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Raw inference result
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
ProviderError: For any provider-related failures
|
|
166
|
+
"""
|
|
167
|
+
if request.product_type != ProductType.PARSE:
|
|
168
|
+
raise ProviderPermanentError(
|
|
169
|
+
f"DoclingParseProvider only supports PARSE product type, got {request.product_type}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
started_at = datetime.now()
|
|
173
|
+
|
|
174
|
+
# Check if file exists
|
|
175
|
+
source_path = Path(request.source_file_path)
|
|
176
|
+
if not source_path.exists():
|
|
177
|
+
raise ProviderPermanentError(f"Source file not found: {source_path}")
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
# Read PDF bytes
|
|
181
|
+
pdf_bytes = source_path.read_bytes()
|
|
182
|
+
|
|
183
|
+
# Call endpoint
|
|
184
|
+
raw_output = self._call_endpoint(pdf_bytes)
|
|
185
|
+
|
|
186
|
+
completed_at = datetime.now()
|
|
187
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
188
|
+
|
|
189
|
+
return RawInferenceResult(
|
|
190
|
+
request=request,
|
|
191
|
+
pipeline=pipeline,
|
|
192
|
+
pipeline_name=pipeline.pipeline_name,
|
|
193
|
+
product_type=request.product_type,
|
|
194
|
+
raw_output=raw_output,
|
|
195
|
+
started_at=started_at,
|
|
196
|
+
completed_at=completed_at,
|
|
197
|
+
latency_in_ms=latency_ms,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
except (ProviderPermanentError, ProviderTransientError, ProviderRateLimitError):
|
|
201
|
+
raise
|
|
202
|
+
except Exception as e:
|
|
203
|
+
raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
|
|
204
|
+
|
|
205
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
206
|
+
"""
|
|
207
|
+
Normalize raw inference result to produce ParseOutput.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
raw_result: Raw inference result from run_inference()
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Inference result with ParseOutput
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
ProviderError: For any normalization failures
|
|
217
|
+
"""
|
|
218
|
+
if raw_result.product_type != ProductType.PARSE:
|
|
219
|
+
raise ProviderPermanentError(
|
|
220
|
+
f"DoclingParseProvider only supports PARSE product type, got {raw_result.product_type}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Extract pages from response
|
|
224
|
+
# Response format:
|
|
225
|
+
# {
|
|
226
|
+
# "pages": [{"page": 1, "markdown": "..."}, ...],
|
|
227
|
+
# "markdown": "...",
|
|
228
|
+
# "docling_document": {...},
|
|
229
|
+
# }
|
|
230
|
+
raw_pages = raw_result.raw_output.get("pages", [])
|
|
231
|
+
full_markdown = raw_result.raw_output.get("markdown", "")
|
|
232
|
+
raw_docling_document = raw_result.raw_output.get("docling_document")
|
|
233
|
+
|
|
234
|
+
# Convert to PageIR list (0-indexed)
|
|
235
|
+
pages: list[PageIR] = []
|
|
236
|
+
for page_data in raw_pages:
|
|
237
|
+
# Docling uses 1-indexed pages, we use 0-indexed
|
|
238
|
+
page_number = page_data.get("page", 1)
|
|
239
|
+
page_index = page_number - 1 if page_number > 0 else 0
|
|
240
|
+
markdown = page_data.get("markdown", "")
|
|
241
|
+
|
|
242
|
+
pages.append(PageIR(page_index=page_index, markdown=markdown))
|
|
243
|
+
|
|
244
|
+
# Sort by page index
|
|
245
|
+
pages.sort(key=lambda p: p.page_index)
|
|
246
|
+
|
|
247
|
+
# If we have pages but no full markdown, concatenate
|
|
248
|
+
if pages and not full_markdown:
|
|
249
|
+
full_markdown = "\n\n".join(p.markdown for p in pages)
|
|
250
|
+
|
|
251
|
+
layout_pages: list[ParseLayoutPageIR] = []
|
|
252
|
+
if raw_docling_document is not None:
|
|
253
|
+
try:
|
|
254
|
+
docling_document = DoclingDocument.model_validate(raw_docling_document)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
raise ProviderPermanentError(f"Failed to validate docling_document payload: {e}") from e
|
|
257
|
+
|
|
258
|
+
layout_pages = _build_docling_layout_pages(
|
|
259
|
+
doc=docling_document,
|
|
260
|
+
raw_pages=[page for page in raw_pages if isinstance(page, dict)],
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
output = ParseOutput(
|
|
264
|
+
task_type="parse",
|
|
265
|
+
example_id=raw_result.request.example_id,
|
|
266
|
+
pipeline_name=raw_result.pipeline_name,
|
|
267
|
+
pages=pages,
|
|
268
|
+
layout_pages=layout_pages,
|
|
269
|
+
markdown=full_markdown,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return InferenceResult(
|
|
273
|
+
request=raw_result.request,
|
|
274
|
+
pipeline_name=raw_result.pipeline_name,
|
|
275
|
+
product_type=raw_result.product_type,
|
|
276
|
+
raw_output=raw_result.raw_output,
|
|
277
|
+
output=output,
|
|
278
|
+
started_at=raw_result.started_at,
|
|
279
|
+
completed_at=raw_result.completed_at,
|
|
280
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
281
|
+
)
|