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,776 @@
|
|
|
1
|
+
"""Provider for Google Document AI PARSE."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from google.api_core.client_options import ClientOptions
|
|
11
|
+
from google.cloud import documentai_v1 as documentai
|
|
12
|
+
from pypdf import PdfReader
|
|
13
|
+
|
|
14
|
+
from parse_bench.inference.providers.base import (
|
|
15
|
+
Provider,
|
|
16
|
+
ProviderConfigError,
|
|
17
|
+
ProviderPermanentError,
|
|
18
|
+
ProviderTransientError,
|
|
19
|
+
)
|
|
20
|
+
from parse_bench.inference.providers.parse.google_docai_layout_normalization import normalize_layout_document
|
|
21
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
22
|
+
from parse_bench.schemas.parse_output import LayoutItemIR, LayoutSegmentIR, PageIR, ParseLayoutPageIR, ParseOutput
|
|
23
|
+
from parse_bench.schemas.pipeline import PipelineSpec
|
|
24
|
+
from parse_bench.schemas.pipeline_io import InferenceRequest, InferenceResult, RawInferenceResult
|
|
25
|
+
from parse_bench.schemas.product import ProductType
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from google.cloud import documentai_v1beta3 as documentai_v1beta3
|
|
29
|
+
except ImportError: # pragma: no cover - dependency guarded by runtime validation
|
|
30
|
+
documentai_v1beta3 = None # type: ignore[assignment]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_REQUIRED_LAYOUT_CONFIG_FIELDS = {
|
|
34
|
+
"return_bounding_boxes",
|
|
35
|
+
"return_images",
|
|
36
|
+
"enable_image_annotation",
|
|
37
|
+
"enable_table_annotation",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_VIRTUAL_PAGE_DIM = 1000.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register_provider("google_docai")
|
|
44
|
+
class GoogleDocAIProvider(Provider):
|
|
45
|
+
"""
|
|
46
|
+
Provider for Google Document AI PARSE.
|
|
47
|
+
|
|
48
|
+
OCR mode uses `documentai_v1`.
|
|
49
|
+
Layout Parser mode uses the first SDK surface that exposes the full layout
|
|
50
|
+
config contract, preferring `documentai_v1beta3` on current installs.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
|
|
54
|
+
super().__init__(provider_name, base_config)
|
|
55
|
+
|
|
56
|
+
self._project_id = self.base_config.get("project_id") or os.getenv("GOOGLE_DOCAI_PROJECT_ID")
|
|
57
|
+
if not self._project_id:
|
|
58
|
+
raise ProviderConfigError(
|
|
59
|
+
"Google Cloud project ID is required. "
|
|
60
|
+
"Set GOOGLE_DOCAI_PROJECT_ID environment variable or pass project_id in base_config."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self._location = self.base_config.get("location") or os.getenv("GOOGLE_DOCAI_LOCATION", "us")
|
|
64
|
+
|
|
65
|
+
self._processor_id = self.base_config.get("processor_id") or os.getenv("GOOGLE_DOCAI_PROCESSOR_ID")
|
|
66
|
+
if not self._processor_id:
|
|
67
|
+
raise ProviderConfigError(
|
|
68
|
+
"Google Document AI processor ID is required. "
|
|
69
|
+
"Set GOOGLE_DOCAI_PROCESSOR_ID environment variable or pass processor_id in base_config."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self._processor_version = self.base_config.get("processor_version") or os.getenv(
|
|
73
|
+
"GOOGLE_DOCAI_PROCESSOR_VERSION"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self._enable_native_pdf_parsing = self.base_config.get("enable_native_pdf_parsing", True)
|
|
77
|
+
self._enable_symbol_detection = self.base_config.get("enable_symbol_detection", False)
|
|
78
|
+
|
|
79
|
+
self._use_layout_parser = self.base_config.get("use_layout_parser", False)
|
|
80
|
+
self._layout_processor_id = (
|
|
81
|
+
self.base_config.get("layout_processor_id")
|
|
82
|
+
or os.getenv("GOOGLE_DOCAI_LAYOUT_PROCESSOR_ID")
|
|
83
|
+
or self._processor_id
|
|
84
|
+
)
|
|
85
|
+
self._chunking_config = self.base_config.get("chunking_config")
|
|
86
|
+
|
|
87
|
+
self._layout_api_surface_label: str | None = None
|
|
88
|
+
self._layout_documentai: Any | None = None
|
|
89
|
+
self._layout_config_fields: set[str] = set()
|
|
90
|
+
if self._use_layout_parser:
|
|
91
|
+
self._layout_api_surface_label, self._layout_documentai = self._resolve_layout_api_surface()
|
|
92
|
+
self._layout_config_fields = set(
|
|
93
|
+
self._layout_documentai.ProcessOptions.LayoutConfig()._pb.DESCRIPTOR.fields_by_name
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _resolve_layout_api_surface(self) -> tuple[str, Any]:
|
|
97
|
+
candidates: list[tuple[str, Any]] = []
|
|
98
|
+
if documentai_v1beta3 is not None:
|
|
99
|
+
candidates.append(("v1beta3", documentai_v1beta3))
|
|
100
|
+
candidates.append(("v1", documentai))
|
|
101
|
+
|
|
102
|
+
for surface_label, module in candidates:
|
|
103
|
+
layout_fields = set(module.ProcessOptions.LayoutConfig()._pb.DESCRIPTOR.fields_by_name)
|
|
104
|
+
if _REQUIRED_LAYOUT_CONFIG_FIELDS.issubset(layout_fields):
|
|
105
|
+
return surface_label, module
|
|
106
|
+
|
|
107
|
+
raise ProviderConfigError(
|
|
108
|
+
"Google DocAI layout mode requires a Document AI SDK surface exposing "
|
|
109
|
+
f"{sorted(_REQUIRED_LAYOUT_CONFIG_FIELDS)}. "
|
|
110
|
+
"Current install does not provide a compatible layout API surface."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _is_pdf_file(self, file_path: str) -> bool:
|
|
114
|
+
try:
|
|
115
|
+
with open(file_path, "rb") as file_handle:
|
|
116
|
+
return file_handle.read(4) == b"%PDF"
|
|
117
|
+
except Exception:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
def _get_page_count(self, file_path: str) -> int:
|
|
121
|
+
if self._is_pdf_file(file_path):
|
|
122
|
+
try:
|
|
123
|
+
reader = PdfReader(file_path)
|
|
124
|
+
return len(reader.pages)
|
|
125
|
+
except Exception:
|
|
126
|
+
return 1
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
def _get_mime_type(self, file_path: str) -> str:
|
|
130
|
+
suffix = Path(file_path).suffix.lower()
|
|
131
|
+
return {
|
|
132
|
+
".pdf": "application/pdf",
|
|
133
|
+
".png": "image/png",
|
|
134
|
+
".jpg": "image/jpeg",
|
|
135
|
+
".jpeg": "image/jpeg",
|
|
136
|
+
".gif": "image/gif",
|
|
137
|
+
".tiff": "image/tiff",
|
|
138
|
+
".tif": "image/tiff",
|
|
139
|
+
".bmp": "image/bmp",
|
|
140
|
+
".webp": "image/webp",
|
|
141
|
+
}.get(suffix, "application/pdf")
|
|
142
|
+
|
|
143
|
+
def _is_image_file(self, file_path: str) -> bool:
|
|
144
|
+
return Path(file_path).suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".tiff", ".tif", ".bmp", ".webp"}
|
|
145
|
+
|
|
146
|
+
def _convert_image_to_pdf(self, file_path: str) -> bytes:
|
|
147
|
+
try:
|
|
148
|
+
import io
|
|
149
|
+
|
|
150
|
+
from PIL import Image
|
|
151
|
+
except ImportError as exc:
|
|
152
|
+
raise ProviderConfigError("Pillow library not installed. Run: pip install Pillow") from exc
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
with Image.open(file_path) as image:
|
|
156
|
+
if image.mode in ("RGBA", "LA", "P"):
|
|
157
|
+
background = Image.new("RGB", image.size, (255, 255, 255))
|
|
158
|
+
if image.mode == "P":
|
|
159
|
+
image = image.convert("RGBA")
|
|
160
|
+
background.paste(image, mask=image.split()[-1] if image.mode == "RGBA" else None)
|
|
161
|
+
image = background
|
|
162
|
+
elif image.mode != "RGB":
|
|
163
|
+
image = image.convert("RGB")
|
|
164
|
+
|
|
165
|
+
pdf_buffer = io.BytesIO()
|
|
166
|
+
image.save(pdf_buffer, format="PDF", resolution=100.0)
|
|
167
|
+
pdf_buffer.seek(0)
|
|
168
|
+
return pdf_buffer.read()
|
|
169
|
+
except Exception as exc: # pragma: no cover - filesystem/PIL failure
|
|
170
|
+
raise ProviderPermanentError(f"Failed to convert image to PDF: {exc}") from exc
|
|
171
|
+
|
|
172
|
+
def _build_layout_config(self, layout_module: Any) -> Any:
|
|
173
|
+
chunking_config = None
|
|
174
|
+
if self._chunking_config:
|
|
175
|
+
chunking_kwargs: dict[str, Any] = {}
|
|
176
|
+
if "chunk_size" in self._chunking_config:
|
|
177
|
+
chunking_kwargs["chunk_size"] = self._chunking_config["chunk_size"]
|
|
178
|
+
if "include_ancestor_headings" in self._chunking_config:
|
|
179
|
+
chunking_kwargs["include_ancestor_headings"] = self._chunking_config["include_ancestor_headings"]
|
|
180
|
+
if chunking_kwargs:
|
|
181
|
+
chunking_config = layout_module.ProcessOptions.LayoutConfig.ChunkingConfig(**chunking_kwargs)
|
|
182
|
+
|
|
183
|
+
# Visual grounding in bench depends on native layout bounding boxes, so this
|
|
184
|
+
# provider is intentionally optimized for the stable Layout Parser surfaces
|
|
185
|
+
# that still expose bbox geometry. Newer parser versions can improve table
|
|
186
|
+
# understanding, but some do not expose layout bboxes and therefore cannot
|
|
187
|
+
# support the visual-grounding column honestly.
|
|
188
|
+
#
|
|
189
|
+
# Keep LLM image annotations enabled because they materially improve picture
|
|
190
|
+
# detection. Keep LLM table annotations disabled because the native
|
|
191
|
+
# `tableBlock` structure is already present on the stable bbox-capable path,
|
|
192
|
+
# and the extra table annotations did not improve merged-cell fidelity in our
|
|
193
|
+
# verification runs.
|
|
194
|
+
kwargs: dict[str, Any] = {
|
|
195
|
+
"chunking_config": chunking_config,
|
|
196
|
+
"return_bounding_boxes": True,
|
|
197
|
+
"return_images": True,
|
|
198
|
+
"enable_image_annotation": True,
|
|
199
|
+
"enable_table_annotation": False,
|
|
200
|
+
}
|
|
201
|
+
if "enable_image_extraction" in self._layout_config_fields:
|
|
202
|
+
kwargs["enable_image_extraction"] = True
|
|
203
|
+
|
|
204
|
+
return layout_module.ProcessOptions.LayoutConfig(**kwargs)
|
|
205
|
+
|
|
206
|
+
def _build_ocr_response(self, document_obj: Any) -> dict[str, Any]:
|
|
207
|
+
raw_response = {
|
|
208
|
+
"text": document_obj.text,
|
|
209
|
+
"mime_type": document_obj.mime_type,
|
|
210
|
+
"pages": [],
|
|
211
|
+
"entities": [],
|
|
212
|
+
"tables": [],
|
|
213
|
+
"mode": "ocr",
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for page in document_obj.pages:
|
|
217
|
+
page_data = {
|
|
218
|
+
"page_number": page.page_number,
|
|
219
|
+
"width": page.dimension.width if page.dimension else None,
|
|
220
|
+
"height": page.dimension.height if page.dimension else None,
|
|
221
|
+
"blocks": [],
|
|
222
|
+
"paragraphs": [],
|
|
223
|
+
"lines": [],
|
|
224
|
+
"tokens": [],
|
|
225
|
+
"tables": [],
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for block in page.blocks:
|
|
229
|
+
block_text = self._get_text_from_layout(block.layout, document_obj.text)
|
|
230
|
+
page_data["blocks"].append(
|
|
231
|
+
{
|
|
232
|
+
"text": block_text,
|
|
233
|
+
"confidence": block.layout.confidence if block.layout else None,
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
for para in page.paragraphs:
|
|
238
|
+
para_text = self._get_text_from_layout(para.layout, document_obj.text)
|
|
239
|
+
para_entry: dict[str, Any] = {
|
|
240
|
+
"text": para_text,
|
|
241
|
+
"confidence": para.layout.confidence if para.layout else None,
|
|
242
|
+
}
|
|
243
|
+
if para.layout and para.layout.bounding_poly and para.layout.bounding_poly.vertices:
|
|
244
|
+
vertices = para.layout.bounding_poly.vertices
|
|
245
|
+
para_entry["y_position"] = min(v.y for v in vertices if v.y is not None) if vertices else None
|
|
246
|
+
if para.layout and para.layout.bounding_poly:
|
|
247
|
+
normalized_vertices = para.layout.bounding_poly.normalized_vertices
|
|
248
|
+
if normalized_vertices and len(normalized_vertices) >= 4:
|
|
249
|
+
para_entry["normalized_bbox"] = {
|
|
250
|
+
"x1": normalized_vertices[0].x or 0.0,
|
|
251
|
+
"y1": normalized_vertices[0].y or 0.0,
|
|
252
|
+
"x2": normalized_vertices[2].x or 0.0,
|
|
253
|
+
"y2": normalized_vertices[2].y or 0.0,
|
|
254
|
+
}
|
|
255
|
+
page_data["paragraphs"].append(para_entry)
|
|
256
|
+
|
|
257
|
+
for line in page.lines:
|
|
258
|
+
line_text = self._get_text_from_layout(line.layout, document_obj.text)
|
|
259
|
+
page_data["lines"].append(
|
|
260
|
+
{
|
|
261
|
+
"text": line_text,
|
|
262
|
+
"confidence": line.layout.confidence if line.layout else None,
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
for table in page.tables:
|
|
267
|
+
table_data = self._extract_table(table, document_obj.text)
|
|
268
|
+
if table.layout and table.layout.bounding_poly and table.layout.bounding_poly.vertices:
|
|
269
|
+
vertices = table.layout.bounding_poly.vertices
|
|
270
|
+
table_data["y_position"] = min(v.y for v in vertices if v.y is not None) if vertices else None
|
|
271
|
+
if table.layout and table.layout.bounding_poly:
|
|
272
|
+
normalized_vertices = table.layout.bounding_poly.normalized_vertices
|
|
273
|
+
if normalized_vertices and len(normalized_vertices) >= 4:
|
|
274
|
+
table_data["normalized_bbox"] = {
|
|
275
|
+
"x1": normalized_vertices[0].x or 0.0,
|
|
276
|
+
"y1": normalized_vertices[0].y or 0.0,
|
|
277
|
+
"x2": normalized_vertices[2].x or 0.0,
|
|
278
|
+
"y2": normalized_vertices[2].y or 0.0,
|
|
279
|
+
}
|
|
280
|
+
page_data["tables"].append(table_data)
|
|
281
|
+
|
|
282
|
+
raw_response["pages"].append(page_data)
|
|
283
|
+
|
|
284
|
+
for entity in document_obj.entities:
|
|
285
|
+
raw_response["entities"].append(
|
|
286
|
+
{
|
|
287
|
+
"type": entity.type_,
|
|
288
|
+
"mention_text": entity.mention_text,
|
|
289
|
+
"confidence": entity.confidence,
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
raw_response["_config"] = {
|
|
294
|
+
"project_id": self._project_id,
|
|
295
|
+
"location": self._location,
|
|
296
|
+
"processor_id": self._processor_id,
|
|
297
|
+
"processor_version": self._processor_version,
|
|
298
|
+
"enable_native_pdf_parsing": self._enable_native_pdf_parsing,
|
|
299
|
+
"enable_symbol_detection": self._enable_symbol_detection,
|
|
300
|
+
"total_pages": len(document_obj.pages),
|
|
301
|
+
}
|
|
302
|
+
return raw_response
|
|
303
|
+
|
|
304
|
+
def _serialize_api_document(self, document_obj: Any) -> dict[str, Any]:
|
|
305
|
+
try:
|
|
306
|
+
from google.protobuf.json_format import MessageToDict # type: ignore[import-untyped]
|
|
307
|
+
except ImportError as exc: # pragma: no cover - protobuf always available with SDK
|
|
308
|
+
raise ProviderConfigError("google.protobuf is required to serialize Document AI payloads.") from exc
|
|
309
|
+
return cast(dict[str, Any], MessageToDict(document_obj._pb))
|
|
310
|
+
|
|
311
|
+
def _materialize_internal_raw_output(
|
|
312
|
+
self,
|
|
313
|
+
raw_payload: dict[str, Any],
|
|
314
|
+
*,
|
|
315
|
+
use_layout_parser: bool,
|
|
316
|
+
) -> dict[str, Any]:
|
|
317
|
+
if "mode" in raw_payload and ("pages" in raw_payload or "blocks" in raw_payload):
|
|
318
|
+
return raw_payload
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
from google.protobuf.json_format import ParseDict
|
|
322
|
+
except ImportError as exc: # pragma: no cover
|
|
323
|
+
raise ProviderConfigError("Google protobuf support is required to parse Document AI payloads.") from exc
|
|
324
|
+
|
|
325
|
+
if use_layout_parser:
|
|
326
|
+
raise ProviderPermanentError(
|
|
327
|
+
"Legacy Google DocAI layout raw outputs are no longer normalized through provider-shaped blocks. "
|
|
328
|
+
"Re-run inference to regenerate raw outputs from the untouched DocAI payload."
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
document_pb = ParseDict(raw_payload, documentai.Document()._pb)
|
|
332
|
+
document_obj = documentai.Document(document_pb)
|
|
333
|
+
return self._build_ocr_response(document_obj)
|
|
334
|
+
|
|
335
|
+
def _materialize_layout_document(self, raw_payload: dict[str, Any]) -> Any:
|
|
336
|
+
if self._layout_documentai is None:
|
|
337
|
+
raise ProviderConfigError("Layout Parser requested without an initialized layout API surface.")
|
|
338
|
+
try:
|
|
339
|
+
from google.protobuf.json_format import ParseDict
|
|
340
|
+
except ImportError as exc: # pragma: no cover
|
|
341
|
+
raise ProviderConfigError("Google protobuf support is required to parse Document AI payloads.") from exc
|
|
342
|
+
|
|
343
|
+
document_pb = ParseDict(raw_payload, self._layout_documentai.Document()._pb)
|
|
344
|
+
return self._layout_documentai.Document(document_pb)
|
|
345
|
+
|
|
346
|
+
def _parse_document(self, file_path: str) -> dict[str, Any]:
|
|
347
|
+
try:
|
|
348
|
+
docai_module = self._layout_documentai if self._use_layout_parser else documentai
|
|
349
|
+
if docai_module is None:
|
|
350
|
+
raise ProviderConfigError("Layout Parser requested without a compatible Document AI SDK surface.")
|
|
351
|
+
|
|
352
|
+
opts = ClientOptions(api_endpoint=f"{self._location}-documentai.googleapis.com")
|
|
353
|
+
client = docai_module.DocumentProcessorServiceClient(client_options=opts)
|
|
354
|
+
|
|
355
|
+
processor_id = str(self._layout_processor_id if self._use_layout_parser else self._processor_id)
|
|
356
|
+
processor_name = self._build_processor_name(processor_id)
|
|
357
|
+
|
|
358
|
+
with open(file_path, "rb") as file_handle:
|
|
359
|
+
file_content = file_handle.read()
|
|
360
|
+
|
|
361
|
+
mime_type = self._get_mime_type(file_path)
|
|
362
|
+
if self._use_layout_parser and self._is_image_file(file_path):
|
|
363
|
+
file_content = self._convert_image_to_pdf(file_path)
|
|
364
|
+
mime_type = "application/pdf"
|
|
365
|
+
|
|
366
|
+
raw_document = docai_module.RawDocument(content=file_content, mime_type=mime_type)
|
|
367
|
+
|
|
368
|
+
if self._use_layout_parser:
|
|
369
|
+
process_options = docai_module.ProcessOptions(layout_config=self._build_layout_config(docai_module))
|
|
370
|
+
else:
|
|
371
|
+
process_options = docai_module.ProcessOptions(
|
|
372
|
+
ocr_config=docai_module.OcrConfig(
|
|
373
|
+
enable_native_pdf_parsing=self._enable_native_pdf_parsing,
|
|
374
|
+
enable_symbol=self._enable_symbol_detection,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
result = client.process_document(
|
|
379
|
+
request=docai_module.ProcessRequest(
|
|
380
|
+
name=processor_name,
|
|
381
|
+
raw_document=raw_document,
|
|
382
|
+
process_options=process_options,
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
return self._serialize_api_document(result.document)
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
error_str = str(exc).lower()
|
|
388
|
+
transient_keywords = ["timeout", "deadline", "unavailable", "503", "502", "504", "connection", "network"]
|
|
389
|
+
if any(keyword in error_str for keyword in transient_keywords):
|
|
390
|
+
raise ProviderTransientError(f"Transient error during Document AI processing: {exc}") from exc
|
|
391
|
+
raise ProviderPermanentError(f"Error during Document AI processing: {exc}") from exc
|
|
392
|
+
|
|
393
|
+
def _build_processor_name(self, processor_id: str) -> str:
|
|
394
|
+
if self._processor_version:
|
|
395
|
+
return (
|
|
396
|
+
f"projects/{self._project_id}/locations/{self._location}/"
|
|
397
|
+
f"processors/{processor_id}/processorVersions/{self._processor_version}"
|
|
398
|
+
)
|
|
399
|
+
return f"projects/{self._project_id}/locations/{self._location}/processors/{processor_id}"
|
|
400
|
+
|
|
401
|
+
def _get_text_from_layout(self, layout: Any, full_text: str) -> str:
|
|
402
|
+
if not layout or not layout.text_anchor or not layout.text_anchor.text_segments:
|
|
403
|
+
return ""
|
|
404
|
+
|
|
405
|
+
text_parts: list[str] = []
|
|
406
|
+
for segment in layout.text_anchor.text_segments:
|
|
407
|
+
start_index = int(segment.start_index) if segment.start_index else 0
|
|
408
|
+
end_index = int(segment.end_index) if segment.end_index else 0
|
|
409
|
+
text_parts.append(full_text[start_index:end_index])
|
|
410
|
+
return "".join(text_parts)
|
|
411
|
+
|
|
412
|
+
def _extract_table(self, table: Any, full_text: str) -> dict[str, Any]:
|
|
413
|
+
table_data: dict[str, Any] = {
|
|
414
|
+
"header_rows": [],
|
|
415
|
+
"body_rows": [],
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
for row in table.header_rows:
|
|
419
|
+
row_data = []
|
|
420
|
+
for cell in row.cells:
|
|
421
|
+
row_data.append(
|
|
422
|
+
{
|
|
423
|
+
"text": self._get_text_from_layout(cell.layout, full_text).strip(),
|
|
424
|
+
"row_span": cell.row_span,
|
|
425
|
+
"col_span": cell.col_span,
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
table_data["header_rows"].append(row_data)
|
|
429
|
+
|
|
430
|
+
for row in table.body_rows:
|
|
431
|
+
row_data = []
|
|
432
|
+
for cell in row.cells:
|
|
433
|
+
row_data.append(
|
|
434
|
+
{
|
|
435
|
+
"text": self._get_text_from_layout(cell.layout, full_text).strip(),
|
|
436
|
+
"row_span": cell.row_span,
|
|
437
|
+
"col_span": cell.col_span,
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
table_data["body_rows"].append(row_data)
|
|
441
|
+
|
|
442
|
+
return table_data
|
|
443
|
+
|
|
444
|
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
|
445
|
+
if request.product_type != ProductType.PARSE:
|
|
446
|
+
raise ProviderPermanentError(
|
|
447
|
+
f"GoogleDocAIProvider only supports PARSE product type, got {request.product_type}"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
started_at = datetime.now()
|
|
451
|
+
file_path = Path(request.source_file_path)
|
|
452
|
+
if not file_path.exists():
|
|
453
|
+
raise ProviderPermanentError(f"File not found: {file_path}")
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
raw_output = self._parse_document(str(file_path))
|
|
457
|
+
completed_at = datetime.now()
|
|
458
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
459
|
+
return RawInferenceResult(
|
|
460
|
+
request=request,
|
|
461
|
+
pipeline=pipeline,
|
|
462
|
+
pipeline_name=pipeline.pipeline_name,
|
|
463
|
+
product_type=request.product_type,
|
|
464
|
+
raw_output=raw_output,
|
|
465
|
+
started_at=started_at,
|
|
466
|
+
completed_at=completed_at,
|
|
467
|
+
latency_in_ms=latency_ms,
|
|
468
|
+
)
|
|
469
|
+
except (ProviderPermanentError, ProviderTransientError):
|
|
470
|
+
raise
|
|
471
|
+
except Exception as exc: # pragma: no cover
|
|
472
|
+
raise ProviderPermanentError(f"Unexpected error during inference: {exc}") from exc
|
|
473
|
+
|
|
474
|
+
def _table_to_html(self, table: dict[str, Any]) -> str:
|
|
475
|
+
html_parts = ["<table>"]
|
|
476
|
+
|
|
477
|
+
if table.get("header_rows"):
|
|
478
|
+
html_parts.append("<thead>")
|
|
479
|
+
for row in table["header_rows"]:
|
|
480
|
+
html_parts.append("<tr>")
|
|
481
|
+
for cell in row:
|
|
482
|
+
colspan = f' colspan="{cell["col_span"]}"' if cell.get("col_span", 1) > 1 else ""
|
|
483
|
+
rowspan = f' rowspan="{cell["row_span"]}"' if cell.get("row_span", 1) > 1 else ""
|
|
484
|
+
html_parts.append(f"<th{colspan}{rowspan}>{cell['text']}</th>")
|
|
485
|
+
html_parts.append("</tr>")
|
|
486
|
+
html_parts.append("</thead>")
|
|
487
|
+
|
|
488
|
+
if table.get("body_rows"):
|
|
489
|
+
html_parts.append("<tbody>")
|
|
490
|
+
for row in table["body_rows"]:
|
|
491
|
+
html_parts.append("<tr>")
|
|
492
|
+
for cell in row:
|
|
493
|
+
colspan = f' colspan="{cell["col_span"]}"' if cell.get("col_span", 1) > 1 else ""
|
|
494
|
+
rowspan = f' rowspan="{cell["row_span"]}"' if cell.get("row_span", 1) > 1 else ""
|
|
495
|
+
html_parts.append(f"<td{colspan}{rowspan}>{cell['text']}</td>")
|
|
496
|
+
html_parts.append("</tr>")
|
|
497
|
+
html_parts.append("</tbody>")
|
|
498
|
+
|
|
499
|
+
html_parts.append("</table>")
|
|
500
|
+
return "\n".join(html_parts)
|
|
501
|
+
|
|
502
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
503
|
+
if raw_result.product_type != ProductType.PARSE:
|
|
504
|
+
raise ProviderPermanentError(
|
|
505
|
+
f"GoogleDocAIProvider only supports PARSE product type, got {raw_result.product_type}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
pipeline_layout = raw_result.pipeline.config.get("use_layout_parser")
|
|
510
|
+
use_layout_parser = pipeline_layout if pipeline_layout is not None else self._use_layout_parser
|
|
511
|
+
if use_layout_parser:
|
|
512
|
+
if isinstance(raw_result.raw_output, dict) and raw_result.raw_output.get("mode") == "layout_parser":
|
|
513
|
+
output = self._normalize_legacy_layout_output(raw_result.raw_output, raw_result)
|
|
514
|
+
else:
|
|
515
|
+
layout_document = self._materialize_layout_document(raw_result.raw_output)
|
|
516
|
+
output = normalize_layout_document(document=layout_document, raw_result=raw_result)
|
|
517
|
+
else:
|
|
518
|
+
raw_output = self._materialize_internal_raw_output(raw_result.raw_output, use_layout_parser=False)
|
|
519
|
+
output = self._normalize_ocr_output(raw_output, raw_result)
|
|
520
|
+
|
|
521
|
+
return InferenceResult(
|
|
522
|
+
request=raw_result.request,
|
|
523
|
+
pipeline_name=raw_result.pipeline_name,
|
|
524
|
+
product_type=raw_result.product_type,
|
|
525
|
+
raw_output=raw_result.raw_output,
|
|
526
|
+
output=output,
|
|
527
|
+
started_at=raw_result.started_at,
|
|
528
|
+
completed_at=raw_result.completed_at,
|
|
529
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
530
|
+
)
|
|
531
|
+
except Exception as exc:
|
|
532
|
+
raise ProviderPermanentError(f"Normalization failed: {exc}") from exc
|
|
533
|
+
|
|
534
|
+
def _normalize_ocr_output(self, raw_output: dict[str, Any], raw_result: RawInferenceResult) -> ParseOutput:
|
|
535
|
+
pages: list[PageIR] = []
|
|
536
|
+
markdown_parts: list[str] = []
|
|
537
|
+
|
|
538
|
+
for page_idx, page_data in enumerate(raw_output.get("pages", [])):
|
|
539
|
+
elements: list[tuple[float, str]] = []
|
|
540
|
+
|
|
541
|
+
for para in page_data.get("paragraphs", []):
|
|
542
|
+
text = para.get("text", "").strip()
|
|
543
|
+
if text:
|
|
544
|
+
elements.append((para.get("y_position", 0.0) or 0.0, text))
|
|
545
|
+
|
|
546
|
+
for table in page_data.get("tables", []):
|
|
547
|
+
elements.append((table.get("y_position", 0.0) or 0.0, self._table_to_html(table)))
|
|
548
|
+
|
|
549
|
+
elements.sort(key=lambda element: element[0])
|
|
550
|
+
page_markdown_parts = [element[1] for element in elements]
|
|
551
|
+
page_markdown = "\n\n".join(page_markdown_parts)
|
|
552
|
+
pages.append(PageIR(page_index=page_idx, markdown=page_markdown))
|
|
553
|
+
markdown_parts.append(page_markdown)
|
|
554
|
+
|
|
555
|
+
return ParseOutput(
|
|
556
|
+
task_type="parse",
|
|
557
|
+
example_id=raw_result.request.example_id,
|
|
558
|
+
pipeline_name=raw_result.pipeline_name,
|
|
559
|
+
pages=pages,
|
|
560
|
+
layout_pages=_build_layout_pages(raw_output),
|
|
561
|
+
markdown="\n\n---\n\n".join(markdown_parts),
|
|
562
|
+
job_id=None,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
def _normalize_legacy_layout_output(
|
|
566
|
+
self,
|
|
567
|
+
raw_output: dict[str, Any],
|
|
568
|
+
raw_result: RawInferenceResult,
|
|
569
|
+
) -> ParseOutput:
|
|
570
|
+
blocks = raw_output.get("blocks", [])
|
|
571
|
+
if not blocks:
|
|
572
|
+
full_text = raw_output.get("text", "")
|
|
573
|
+
return ParseOutput(
|
|
574
|
+
task_type="parse",
|
|
575
|
+
example_id=raw_result.request.example_id,
|
|
576
|
+
pipeline_name=raw_result.pipeline_name,
|
|
577
|
+
pages=[PageIR(page_index=0, markdown=full_text)],
|
|
578
|
+
markdown=full_text,
|
|
579
|
+
job_id=None,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
page_content: dict[int, list[str]] = {}
|
|
583
|
+
all_content: list[str] = []
|
|
584
|
+
for block in blocks:
|
|
585
|
+
markdown = _legacy_block_to_markdown(block)
|
|
586
|
+
if not markdown:
|
|
587
|
+
continue
|
|
588
|
+
all_content.append(markdown)
|
|
589
|
+
page_span = block.get("page_span")
|
|
590
|
+
if page_span:
|
|
591
|
+
page_start = page_span.get("page_start", 1) - 1
|
|
592
|
+
page_end = page_span.get("page_end", page_start + 1) - 1
|
|
593
|
+
for page_idx in range(page_start, page_end + 1):
|
|
594
|
+
page_content.setdefault(page_idx, []).append(markdown)
|
|
595
|
+
else:
|
|
596
|
+
page_content.setdefault(0, []).append(markdown)
|
|
597
|
+
|
|
598
|
+
pages = [
|
|
599
|
+
PageIR(page_index=page_idx, markdown="\n\n".join(page_content[page_idx]))
|
|
600
|
+
for page_idx in sorted(page_content)
|
|
601
|
+
]
|
|
602
|
+
layout_pages_payload = raw_output.get("layout_pages")
|
|
603
|
+
if not layout_pages_payload:
|
|
604
|
+
raise ProviderPermanentError(
|
|
605
|
+
"Legacy layout raw output is missing layout_pages. Re-run inference with the native layout rewrite."
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
return ParseOutput(
|
|
609
|
+
task_type="parse",
|
|
610
|
+
example_id=raw_result.request.example_id,
|
|
611
|
+
pipeline_name=raw_result.pipeline_name,
|
|
612
|
+
pages=pages,
|
|
613
|
+
layout_pages=[ParseLayoutPageIR.model_validate(page_data) for page_data in layout_pages_payload],
|
|
614
|
+
markdown="\n\n".join(all_content),
|
|
615
|
+
job_id=None,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _build_layout_pages(raw_output: dict[str, Any]) -> list[ParseLayoutPageIR]:
|
|
620
|
+
layout_pages: list[ParseLayoutPageIR] = []
|
|
621
|
+
|
|
622
|
+
for page_idx, page_data in enumerate(raw_output.get("pages", [])):
|
|
623
|
+
items: list[LayoutItemIR] = []
|
|
624
|
+
|
|
625
|
+
for para in page_data.get("paragraphs", []):
|
|
626
|
+
bbox_data = para.get("normalized_bbox")
|
|
627
|
+
if not bbox_data:
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
text = para.get("text", "").strip()
|
|
631
|
+
if not text:
|
|
632
|
+
continue
|
|
633
|
+
|
|
634
|
+
x1 = float(bbox_data.get("x1", 0.0))
|
|
635
|
+
y1 = float(bbox_data.get("y1", 0.0))
|
|
636
|
+
x2 = float(bbox_data.get("x2", 0.0))
|
|
637
|
+
y2 = float(bbox_data.get("y2", 0.0))
|
|
638
|
+
w = x2 - x1
|
|
639
|
+
h = y2 - y1
|
|
640
|
+
if w <= 0 or h <= 0:
|
|
641
|
+
continue
|
|
642
|
+
|
|
643
|
+
conf_raw = para.get("confidence")
|
|
644
|
+
try:
|
|
645
|
+
confidence = float(conf_raw) if conf_raw is not None else 1.0
|
|
646
|
+
except (TypeError, ValueError):
|
|
647
|
+
confidence = 1.0
|
|
648
|
+
|
|
649
|
+
seg = LayoutSegmentIR(x=x1, y=y1, w=w, h=h, confidence=confidence, label="Text")
|
|
650
|
+
items.append(LayoutItemIR(type="text", value=text, bbox=seg, layout_segments=[seg]))
|
|
651
|
+
|
|
652
|
+
for table in page_data.get("tables", []):
|
|
653
|
+
bbox_data = table.get("normalized_bbox")
|
|
654
|
+
if not bbox_data:
|
|
655
|
+
continue
|
|
656
|
+
|
|
657
|
+
x1 = float(bbox_data.get("x1", 0.0))
|
|
658
|
+
y1 = float(bbox_data.get("y1", 0.0))
|
|
659
|
+
x2 = float(bbox_data.get("x2", 0.0))
|
|
660
|
+
y2 = float(bbox_data.get("y2", 0.0))
|
|
661
|
+
w = x2 - x1
|
|
662
|
+
h = y2 - y1
|
|
663
|
+
if w <= 0 or h <= 0:
|
|
664
|
+
continue
|
|
665
|
+
|
|
666
|
+
seg = LayoutSegmentIR(x=x1, y=y1, w=w, h=h, confidence=1.0, label="Table")
|
|
667
|
+
table_html = _table_dict_to_html(table)
|
|
668
|
+
items.append(LayoutItemIR(type="table", value=table_html, bbox=seg, layout_segments=[seg]))
|
|
669
|
+
|
|
670
|
+
if items:
|
|
671
|
+
layout_pages.append(
|
|
672
|
+
ParseLayoutPageIR(
|
|
673
|
+
page_number=page_idx + 1,
|
|
674
|
+
width=_VIRTUAL_PAGE_DIM,
|
|
675
|
+
height=_VIRTUAL_PAGE_DIM,
|
|
676
|
+
items=items,
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
return layout_pages
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _table_dict_to_html(table: dict[str, Any]) -> str:
|
|
684
|
+
parts = ["<table>"]
|
|
685
|
+
for section, tag in [("header_rows", "th"), ("body_rows", "td")]:
|
|
686
|
+
rows = table.get(section, [])
|
|
687
|
+
if not rows:
|
|
688
|
+
continue
|
|
689
|
+
wrapper = "thead" if tag == "th" else "tbody"
|
|
690
|
+
parts.append(f"<{wrapper}>")
|
|
691
|
+
for row in rows:
|
|
692
|
+
parts.append("<tr>")
|
|
693
|
+
for cell in row:
|
|
694
|
+
colspan = f' colspan="{cell["col_span"]}"' if cell.get("col_span", 1) > 1 else ""
|
|
695
|
+
rowspan = f' rowspan="{cell["row_span"]}"' if cell.get("row_span", 1) > 1 else ""
|
|
696
|
+
parts.append(f"<{tag}{colspan}{rowspan}>{cell.get('text', '')}</{tag}>")
|
|
697
|
+
parts.append("</tr>")
|
|
698
|
+
parts.append(f"</{wrapper}>")
|
|
699
|
+
parts.append("</table>")
|
|
700
|
+
return "\n".join(parts)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _legacy_block_to_markdown(block: dict[str, Any]) -> str:
|
|
704
|
+
block_type = block.get("type")
|
|
705
|
+
parts: list[str] = []
|
|
706
|
+
|
|
707
|
+
if block_type == "text":
|
|
708
|
+
text = block.get("text", "").strip()
|
|
709
|
+
text_type = block.get("text_type", "")
|
|
710
|
+
if text:
|
|
711
|
+
if text_type == "heading-1":
|
|
712
|
+
parts.append(f"# {text}")
|
|
713
|
+
elif text_type == "heading-2":
|
|
714
|
+
parts.append(f"## {text}")
|
|
715
|
+
elif text_type == "heading-3":
|
|
716
|
+
parts.append(f"### {text}")
|
|
717
|
+
elif text_type and text_type.startswith("heading"):
|
|
718
|
+
parts.append(f"#### {text}")
|
|
719
|
+
else:
|
|
720
|
+
parts.append(text)
|
|
721
|
+
for child in block.get("children", []):
|
|
722
|
+
child_md = _legacy_block_to_markdown(child)
|
|
723
|
+
if child_md:
|
|
724
|
+
parts.append(child_md)
|
|
725
|
+
|
|
726
|
+
elif block_type == "table":
|
|
727
|
+
parts.append(_legacy_layout_table_to_html(block))
|
|
728
|
+
|
|
729
|
+
elif block_type == "list":
|
|
730
|
+
for entry in block.get("entries", []):
|
|
731
|
+
entry_md = _legacy_block_to_markdown(entry)
|
|
732
|
+
if entry_md:
|
|
733
|
+
parts.append(f"- {entry_md}")
|
|
734
|
+
|
|
735
|
+
return "\n\n".join(part for part in parts if part)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _legacy_layout_table_to_html(table_block: dict[str, Any]) -> str:
|
|
739
|
+
html_parts = ["<table>"]
|
|
740
|
+
|
|
741
|
+
header_rows = table_block.get("header_rows", [])
|
|
742
|
+
if header_rows:
|
|
743
|
+
html_parts.append("<thead>")
|
|
744
|
+
for row in header_rows:
|
|
745
|
+
html_parts.append("<tr>")
|
|
746
|
+
for cell in row:
|
|
747
|
+
colspan = f' colspan="{cell["col_span"]}"' if cell.get("col_span", 1) > 1 else ""
|
|
748
|
+
rowspan = f' rowspan="{cell["row_span"]}"' if cell.get("row_span", 1) > 1 else ""
|
|
749
|
+
html_parts.append(f"<th{colspan}{rowspan}>{_legacy_extract_cell_text(cell)}</th>")
|
|
750
|
+
html_parts.append("</tr>")
|
|
751
|
+
html_parts.append("</thead>")
|
|
752
|
+
|
|
753
|
+
body_rows = table_block.get("body_rows", [])
|
|
754
|
+
if body_rows:
|
|
755
|
+
html_parts.append("<tbody>")
|
|
756
|
+
for row in body_rows:
|
|
757
|
+
html_parts.append("<tr>")
|
|
758
|
+
for cell in row:
|
|
759
|
+
colspan = f' colspan="{cell["col_span"]}"' if cell.get("col_span", 1) > 1 else ""
|
|
760
|
+
rowspan = f' rowspan="{cell["row_span"]}"' if cell.get("row_span", 1) > 1 else ""
|
|
761
|
+
html_parts.append(f"<td{colspan}{rowspan}>{_legacy_extract_cell_text(cell)}</td>")
|
|
762
|
+
html_parts.append("</tr>")
|
|
763
|
+
html_parts.append("</tbody>")
|
|
764
|
+
|
|
765
|
+
html_parts.append("</table>")
|
|
766
|
+
return "\n".join(html_parts)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _legacy_extract_cell_text(cell: dict[str, Any]) -> str:
|
|
770
|
+
texts: list[str] = []
|
|
771
|
+
for block in cell.get("blocks", []):
|
|
772
|
+
if block.get("type") == "text":
|
|
773
|
+
text = block.get("text", "").strip()
|
|
774
|
+
if text:
|
|
775
|
+
texts.append(text)
|
|
776
|
+
return " ".join(texts)
|