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,515 @@
|
|
|
1
|
+
"""Provider for Amazon Nova vision-based PARSE via the Bedrock Converse API.
|
|
2
|
+
|
|
3
|
+
Amazon Nova 2 models are served through Amazon Bedrock's ``converse`` API
|
|
4
|
+
(``bedrock-runtime``). Pages are rendered to images and sent as Converse
|
|
5
|
+
``image`` content blocks, mirroring the OpenAI/Anthropic vision parse
|
|
6
|
+
providers so the resulting metrics are comparable.
|
|
7
|
+
|
|
8
|
+
Nova 2 Lite is not available for in-region inference in most Regions, so
|
|
9
|
+
pipelines address it through a cross-Region inference profile ID
|
|
10
|
+
(``us.``/``eu.``/``jp.`` for a geography, ``global.`` for worldwide routing).
|
|
11
|
+
See the model card:
|
|
12
|
+
https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-amazon-nova-2-lite.html
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import io
|
|
16
|
+
import os
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, NoReturn
|
|
20
|
+
|
|
21
|
+
from PIL import Image
|
|
22
|
+
|
|
23
|
+
from parse_bench.inference.providers.base import (
|
|
24
|
+
Provider,
|
|
25
|
+
ProviderConfigError,
|
|
26
|
+
ProviderPermanentError,
|
|
27
|
+
ProviderRateLimitError,
|
|
28
|
+
ProviderTransientError,
|
|
29
|
+
)
|
|
30
|
+
from parse_bench.inference.providers.parse._layout_utils import (
|
|
31
|
+
SYSTEM_PROMPT_LAYOUT,
|
|
32
|
+
USER_PROMPT_LAYOUT,
|
|
33
|
+
build_layout_pages,
|
|
34
|
+
close_open_ended_bands,
|
|
35
|
+
extract_layout_blocks_lenient,
|
|
36
|
+
items_to_markdown,
|
|
37
|
+
parse_layout_blocks,
|
|
38
|
+
)
|
|
39
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
40
|
+
from parse_bench.schemas.parse_output import PageIR, ParseLayoutPageIR, ParseOutput
|
|
41
|
+
from parse_bench.schemas.pipeline import PipelineSpec
|
|
42
|
+
from parse_bench.schemas.pipeline_io import (
|
|
43
|
+
InferenceRequest,
|
|
44
|
+
InferenceResult,
|
|
45
|
+
RawInferenceResult,
|
|
46
|
+
)
|
|
47
|
+
from parse_bench.schemas.product import ProductType
|
|
48
|
+
|
|
49
|
+
# Cross-Region inference profile prefixes. Bedrock bills a geo profile
|
|
50
|
+
# (us./eu./jp.) at the source Region's standard on-demand rate and the global
|
|
51
|
+
# profile at its own (cheaper) cross-region-global rate, so pricing is keyed by
|
|
52
|
+
# the base model ID plus whether the request goes through `global.`.
|
|
53
|
+
_GEO_PROFILE_PREFIXES = ("us.", "eu.", "jp.", "apac.", "global.")
|
|
54
|
+
|
|
55
|
+
# Amazon Bedrock on-demand token pricing, USD per million tokens (input, output),
|
|
56
|
+
# us-east-1 "Standard" service tier. Values come from the AWS Price List API
|
|
57
|
+
# (offer code AmazonBedrock, us-east-1), usage types
|
|
58
|
+
# `USE1-Nova2.0Lite-{input,output}-tokens` and their `-cross-region-global`
|
|
59
|
+
# variants. Reasoning tokens are billed at the output rate.
|
|
60
|
+
# Source: https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/us-east-1/index.json
|
|
61
|
+
_NOVA_PRICING_PER_M: dict[str, tuple[float, float]] = {
|
|
62
|
+
# base-model-id: (input_per_M, output_per_M)
|
|
63
|
+
"amazon.nova-2-lite-v1:0": (0.33, 2.75),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Same models routed through the `global.` inference profile.
|
|
67
|
+
_NOVA_GLOBAL_PRICING_PER_M: dict[str, tuple[float, float]] = {
|
|
68
|
+
"amazon.nova-2-lite-v1:0": (0.30, 2.50),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Reasoning ("extended thinking") is disabled by default on Nova 2; when enabled
|
|
72
|
+
# an effort level is required.
|
|
73
|
+
_VALID_REASONING_EFFORTS = ("low", "medium", "high")
|
|
74
|
+
|
|
75
|
+
# Bedrock client error codes that the runner should retry.
|
|
76
|
+
_TRANSIENT_ERROR_CODES = frozenset(
|
|
77
|
+
{
|
|
78
|
+
"InternalServerException",
|
|
79
|
+
"ModelNotReadyException",
|
|
80
|
+
"ModelTimeoutException",
|
|
81
|
+
"ServiceUnavailableException",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
_RATE_LIMIT_ERROR_CODES = frozenset(
|
|
85
|
+
{
|
|
86
|
+
"ThrottlingException",
|
|
87
|
+
"TooManyRequestsException",
|
|
88
|
+
"ServiceQuotaExceededException",
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@register_provider("amazon_nova")
|
|
94
|
+
class AmazonNovaProvider(Provider):
|
|
95
|
+
"""
|
|
96
|
+
Provider for Amazon Nova vision-based document parsing on Bedrock.
|
|
97
|
+
|
|
98
|
+
Renders PDF pages to images and sends each page through the Bedrock
|
|
99
|
+
Converse API with the shared layout prompt, so one pipeline produces both
|
|
100
|
+
the markdown and the per-element bounding boxes.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# Nova image-understanding limits: 8,000 x 8,000 px, 25 MB total request
|
|
104
|
+
# payload. Images are base64-encoded on the wire, so the raw byte budget is
|
|
105
|
+
# 3/4 of the payload cap.
|
|
106
|
+
# Source: https://docs.aws.amazon.com/nova/latest/nova2-userguide/using-multimodal-models.html
|
|
107
|
+
MAX_IMAGE_DIMENSION = 8000
|
|
108
|
+
MAX_IMAGE_SIZE_BYTES = int(24 * 1024 * 1024 * 3 / 4) # ~18 MB raw -> ~24 MB base64
|
|
109
|
+
|
|
110
|
+
def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
|
|
111
|
+
"""
|
|
112
|
+
Initialize the provider.
|
|
113
|
+
|
|
114
|
+
:param provider_name: Name of the provider
|
|
115
|
+
:param base_config: Optional configuration with:
|
|
116
|
+
- `model`: Bedrock model or inference profile ID
|
|
117
|
+
(default: "us.amazon.nova-2-lite-v1:0")
|
|
118
|
+
- `region`: AWS Region for the bedrock-runtime endpoint
|
|
119
|
+
(default: `$AWS_REGION`, else "us-east-1")
|
|
120
|
+
- `dpi`: DPI for PDF to image conversion (default: 150)
|
|
121
|
+
- `max_tokens`: Max tokens per response (default: 8192, model max 64K)
|
|
122
|
+
- `timeout`: Per-request read timeout in seconds (default: 300)
|
|
123
|
+
- `reasoning_effort`: "low", "medium" or "high" to enable extended
|
|
124
|
+
thinking. Unset (default) leaves reasoning disabled, which is the
|
|
125
|
+
Bedrock default for Nova 2.
|
|
126
|
+
- `temperature`: sampling temperature (default: 0 when reasoning is
|
|
127
|
+
disabled, omitted when it is enabled)
|
|
128
|
+
- `top_p`: nucleus sampling threshold (default: omitted)
|
|
129
|
+
"""
|
|
130
|
+
super().__init__(provider_name, base_config)
|
|
131
|
+
|
|
132
|
+
self._model: str = self.base_config.get("model", "us.amazon.nova-2-lite-v1:0")
|
|
133
|
+
self._region: str = self.base_config.get("region") or os.environ.get("AWS_REGION") or "us-east-1"
|
|
134
|
+
self._dpi = self.base_config.get("dpi", 150)
|
|
135
|
+
self._max_tokens = self.base_config.get("max_tokens", 8192)
|
|
136
|
+
self._timeout = self.base_config.get("timeout", 300)
|
|
137
|
+
self._reasoning_effort = self.base_config.get("reasoning_effort", None)
|
|
138
|
+
self._top_p = self.base_config.get("top_p", None)
|
|
139
|
+
if self._reasoning_effort is not None and self._reasoning_effort not in _VALID_REASONING_EFFORTS:
|
|
140
|
+
raise ProviderConfigError(
|
|
141
|
+
f"Invalid reasoning_effort '{self._reasoning_effort}'. Must be one of {_VALID_REASONING_EFFORTS}."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Nova 2 rejects sampling parameters when reasoning runs at high effort,
|
|
145
|
+
# so temperature defaults to 0 (deterministic parsing) only while
|
|
146
|
+
# reasoning is disabled.
|
|
147
|
+
# https://docs.aws.amazon.com/nova/latest/nova2-userguide/using-converse-api.html
|
|
148
|
+
default_temperature = 0 if self._reasoning_effort is None else None
|
|
149
|
+
self._temperature = self.base_config.get("temperature", default_temperature)
|
|
150
|
+
if self._reasoning_effort is not None and (self._temperature is not None or self._top_p is not None):
|
|
151
|
+
raise ProviderConfigError(
|
|
152
|
+
"Nova 2 rejects temperature/top_p when reasoning is enabled; "
|
|
153
|
+
"drop them from the pipeline config or unset reasoning_effort."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if not os.environ.get("AWS_ACCESS_KEY_ID") and not os.environ.get("AWS_BEARER_TOKEN_BEDROCK"):
|
|
157
|
+
raise ProviderConfigError(
|
|
158
|
+
"No AWS credentials found. Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY "
|
|
159
|
+
"or AWS_BEARER_TOKEN_BEDROCK for Bedrock access."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
import boto3
|
|
164
|
+
from botocore.config import Config
|
|
165
|
+
|
|
166
|
+
self._client = boto3.client(
|
|
167
|
+
"bedrock-runtime",
|
|
168
|
+
region_name=self._region,
|
|
169
|
+
config=Config(
|
|
170
|
+
read_timeout=self._timeout,
|
|
171
|
+
connect_timeout=30,
|
|
172
|
+
retries={"max_attempts": 3, "mode": "standard"},
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
except ImportError as e:
|
|
176
|
+
raise ProviderConfigError("boto3 package not installed. Run: pip install boto3") from e
|
|
177
|
+
|
|
178
|
+
def _base_model_id(self) -> str:
|
|
179
|
+
"""Strip any cross-Region inference profile prefix from the model ID."""
|
|
180
|
+
for prefix in _GEO_PROFILE_PREFIXES:
|
|
181
|
+
if self._model.startswith(prefix):
|
|
182
|
+
return self._model[len(prefix) :]
|
|
183
|
+
return self._model
|
|
184
|
+
|
|
185
|
+
def _get_pricing(self) -> tuple[float, float]:
|
|
186
|
+
"""Return (input_rate, output_rate) in USD per million tokens."""
|
|
187
|
+
table = _NOVA_GLOBAL_PRICING_PER_M if self._model.startswith("global.") else _NOVA_PRICING_PER_M
|
|
188
|
+
return table.get(self._base_model_id(), (0.0, 0.0))
|
|
189
|
+
|
|
190
|
+
def _raise_bedrock_error(self, e: Exception) -> NoReturn:
|
|
191
|
+
"""Classify a Bedrock/botocore exception as transient, rate-limited or permanent."""
|
|
192
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
193
|
+
|
|
194
|
+
if isinstance(e, ClientError):
|
|
195
|
+
code = e.response.get("Error", {}).get("Code", "")
|
|
196
|
+
if code in _RATE_LIMIT_ERROR_CODES:
|
|
197
|
+
raise ProviderRateLimitError(f"Bedrock rate limited ({code}): {e}") from e
|
|
198
|
+
if code in _TRANSIENT_ERROR_CODES:
|
|
199
|
+
raise ProviderTransientError(f"Transient Bedrock error ({code}): {e}") from e
|
|
200
|
+
raise ProviderPermanentError(f"Error calling Bedrock Converse API ({code}): {e}") from e
|
|
201
|
+
|
|
202
|
+
if isinstance(e, BotoCoreError):
|
|
203
|
+
# Connection/read timeouts and endpoint resolution blips.
|
|
204
|
+
raise ProviderTransientError(f"Transient error calling Bedrock Converse API: {e}") from e
|
|
205
|
+
|
|
206
|
+
raise ProviderPermanentError(f"Error calling Bedrock Converse API: {e}") from e
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _extract_usage(response: dict[str, Any]) -> dict[str, int]:
|
|
210
|
+
"""Extract token counts from a Converse response.
|
|
211
|
+
|
|
212
|
+
Nova bills reasoning tokens as output tokens and does not report them
|
|
213
|
+
separately, so ``thinking_tokens`` is always 0 — the spend is already
|
|
214
|
+
inside ``output_tokens``.
|
|
215
|
+
"""
|
|
216
|
+
usage = response.get("usage") or {}
|
|
217
|
+
input_tok = int(usage.get("inputTokens", 0) or 0)
|
|
218
|
+
output_tok = int(usage.get("outputTokens", 0) or 0)
|
|
219
|
+
total_tok = int(usage.get("totalTokens", 0) or 0) or (input_tok + output_tok)
|
|
220
|
+
return {
|
|
221
|
+
"input_tokens": input_tok,
|
|
222
|
+
"output_tokens": output_tok,
|
|
223
|
+
"thinking_tokens": 0,
|
|
224
|
+
"total_tokens": total_tok,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _extract_text(response: dict[str, Any]) -> str:
|
|
229
|
+
"""Concatenate the text blocks of a Converse response.
|
|
230
|
+
|
|
231
|
+
Reasoning blocks (``reasoningContent``) are skipped — Nova 2 returns
|
|
232
|
+
them as ``[REDACTED]`` and they are not part of the parsed document.
|
|
233
|
+
"""
|
|
234
|
+
content = response.get("output", {}).get("message", {}).get("content", []) or []
|
|
235
|
+
return "".join(block["text"] for block in content if isinstance(block, dict) and "text" in block)
|
|
236
|
+
|
|
237
|
+
def _prepare_image_for_api(self, image: Image.Image) -> Image.Image:
|
|
238
|
+
"""Resize an image that exceeds Nova's pixel limits."""
|
|
239
|
+
width, height = image.size
|
|
240
|
+
max_dim = max(width, height)
|
|
241
|
+
|
|
242
|
+
if max_dim <= self.MAX_IMAGE_DIMENSION:
|
|
243
|
+
return image
|
|
244
|
+
|
|
245
|
+
scale = self.MAX_IMAGE_DIMENSION / max_dim
|
|
246
|
+
return image.resize((int(width * scale), int(height * scale)), Image.Resampling.LANCZOS)
|
|
247
|
+
|
|
248
|
+
def _image_to_jpeg_bytes(self, image: Image.Image) -> bytes:
|
|
249
|
+
"""Encode a PIL image as JPEG bytes within Nova's payload budget."""
|
|
250
|
+
image = self._prepare_image_for_api(image)
|
|
251
|
+
|
|
252
|
+
if image.mode in ("RGBA", "P"):
|
|
253
|
+
image = image.convert("RGB")
|
|
254
|
+
|
|
255
|
+
quality = 85
|
|
256
|
+
min_quality = 20
|
|
257
|
+
|
|
258
|
+
while quality >= min_quality:
|
|
259
|
+
buffer = io.BytesIO()
|
|
260
|
+
image.save(buffer, format="JPEG", quality=quality)
|
|
261
|
+
data = buffer.getvalue()
|
|
262
|
+
if len(data) <= self.MAX_IMAGE_SIZE_BYTES:
|
|
263
|
+
return data
|
|
264
|
+
quality -= 10
|
|
265
|
+
|
|
266
|
+
while True:
|
|
267
|
+
width, height = image.size
|
|
268
|
+
new_width, new_height = int(width * 0.8), int(height * 0.8)
|
|
269
|
+
if new_width < 100 or new_height < 100:
|
|
270
|
+
break
|
|
271
|
+
|
|
272
|
+
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
273
|
+
buffer = io.BytesIO()
|
|
274
|
+
image.save(buffer, format="JPEG", quality=min_quality)
|
|
275
|
+
data = buffer.getvalue()
|
|
276
|
+
if len(data) <= self.MAX_IMAGE_SIZE_BYTES:
|
|
277
|
+
return data
|
|
278
|
+
|
|
279
|
+
buffer = io.BytesIO()
|
|
280
|
+
image.save(buffer, format="JPEG", quality=min_quality)
|
|
281
|
+
return buffer.getvalue()
|
|
282
|
+
|
|
283
|
+
def _pdf_to_images(self, pdf_path: str) -> list[Image.Image]:
|
|
284
|
+
"""Convert PDF pages to images."""
|
|
285
|
+
try:
|
|
286
|
+
from pdf2image import convert_from_path
|
|
287
|
+
except ImportError as e:
|
|
288
|
+
raise ProviderConfigError("pdf2image package not installed. Run: pip install pdf2image") from e
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
return convert_from_path(pdf_path, dpi=self._dpi)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
raise ProviderPermanentError(f"Failed to convert PDF to images: {e}") from e
|
|
294
|
+
|
|
295
|
+
def _converse(self, image: Image.Image, system_prompt: str, user_prompt: str) -> tuple[str, dict[str, int], str]:
|
|
296
|
+
"""Send one page image to Bedrock Converse and return (text, usage, stop_reason)."""
|
|
297
|
+
image_bytes = self._image_to_jpeg_bytes(image)
|
|
298
|
+
|
|
299
|
+
inference_config: dict[str, Any] = {"maxTokens": self._max_tokens}
|
|
300
|
+
if self._temperature is not None:
|
|
301
|
+
inference_config["temperature"] = self._temperature
|
|
302
|
+
if self._top_p is not None:
|
|
303
|
+
inference_config["topP"] = self._top_p
|
|
304
|
+
|
|
305
|
+
kwargs: dict[str, Any] = {
|
|
306
|
+
"modelId": self._model,
|
|
307
|
+
"system": [{"text": system_prompt}],
|
|
308
|
+
"messages": [
|
|
309
|
+
{
|
|
310
|
+
"role": "user",
|
|
311
|
+
"content": [
|
|
312
|
+
{"image": {"format": "jpeg", "source": {"bytes": image_bytes}}},
|
|
313
|
+
{"text": user_prompt},
|
|
314
|
+
],
|
|
315
|
+
}
|
|
316
|
+
],
|
|
317
|
+
"inferenceConfig": inference_config,
|
|
318
|
+
}
|
|
319
|
+
if self._reasoning_effort is not None:
|
|
320
|
+
kwargs["additionalModelRequestFields"] = {
|
|
321
|
+
"reasoningConfig": {"type": "enabled", "maxReasoningEffort": self._reasoning_effort}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
response = self._client.converse(**kwargs)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
self._raise_bedrock_error(e)
|
|
328
|
+
|
|
329
|
+
stop_reason = str(response.get("stopReason", ""))
|
|
330
|
+
text = self._extract_text(response)
|
|
331
|
+
|
|
332
|
+
# Bedrock's built-in content filter returns HTTP 200 with a canned
|
|
333
|
+
# notice in the text block ("The generated text has been blocked by our
|
|
334
|
+
# content filters.") and zero output tokens. That notice is not page
|
|
335
|
+
# content, so it must never reach ParseOutput. Treated as transient so
|
|
336
|
+
# the runner retries — matching how the Gemini provider handles blocked
|
|
337
|
+
# responses — and a page that stays blocked surfaces as a failed doc
|
|
338
|
+
# rather than as an empty parse.
|
|
339
|
+
if stop_reason == "content_filtered":
|
|
340
|
+
raise ProviderTransientError(f"Bedrock content filter blocked the response (stopReason={stop_reason})")
|
|
341
|
+
if not text.strip():
|
|
342
|
+
raise ProviderTransientError(f"Bedrock Converse returned no text (stopReason={stop_reason or 'unknown'})")
|
|
343
|
+
|
|
344
|
+
return text, self._extract_usage(response), stop_reason
|
|
345
|
+
|
|
346
|
+
def _parse_image_with_layout(self, image: Image.Image) -> tuple[list[dict[str, Any]], str, dict[str, int], str]:
|
|
347
|
+
"""Parse a page image to layout-annotated markdown blocks."""
|
|
348
|
+
text, usage, stop_reason = self._converse(image, SYSTEM_PROMPT_LAYOUT, USER_PROMPT_LAYOUT)
|
|
349
|
+
# Prefer the lenient reader (Nova uses <TABLE>/<p> wrappers and leaves
|
|
350
|
+
# them unclosed); fall back to the strict shared parser if it finds
|
|
351
|
+
# nothing, so this can never score worse than the default path.
|
|
352
|
+
items = extract_layout_blocks_lenient(text) or parse_layout_blocks(text)
|
|
353
|
+
return close_open_ended_bands(items), text, usage, stop_reason
|
|
354
|
+
|
|
355
|
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
|
356
|
+
"""
|
|
357
|
+
Run inference and return raw results.
|
|
358
|
+
|
|
359
|
+
:param pipeline: Pipeline specification
|
|
360
|
+
:param request: Inference request
|
|
361
|
+
:return: Raw inference result
|
|
362
|
+
"""
|
|
363
|
+
if request.product_type != ProductType.PARSE:
|
|
364
|
+
raise ProviderPermanentError(
|
|
365
|
+
f"AmazonNovaProvider only supports PARSE product type, got {request.product_type}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
source_path = Path(request.source_file_path)
|
|
369
|
+
if not source_path.exists():
|
|
370
|
+
raise ProviderPermanentError(f"Source file not found: {source_path}")
|
|
371
|
+
|
|
372
|
+
supported_extensions = {".pdf", ".png", ".jpg", ".jpeg"}
|
|
373
|
+
if source_path.suffix.lower() not in supported_extensions:
|
|
374
|
+
raise ProviderPermanentError(
|
|
375
|
+
f"AmazonNovaProvider supports {supported_extensions}, got {source_path.suffix}"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
started_at = datetime.now()
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
page_usages: list[dict[str, int]] = []
|
|
382
|
+
|
|
383
|
+
if source_path.suffix.lower() == ".pdf":
|
|
384
|
+
images = self._pdf_to_images(str(source_path))
|
|
385
|
+
else:
|
|
386
|
+
images = [Image.open(source_path)]
|
|
387
|
+
|
|
388
|
+
pages: list[dict[str, Any]] = []
|
|
389
|
+
for page_index, image in enumerate(images):
|
|
390
|
+
items, raw_content, usage, stop_reason = self._parse_image_with_layout(image)
|
|
391
|
+
page_usages.append(usage)
|
|
392
|
+
pages.append(
|
|
393
|
+
{
|
|
394
|
+
"page_index": page_index,
|
|
395
|
+
"items": items,
|
|
396
|
+
"raw_content": raw_content,
|
|
397
|
+
"stop_reason": stop_reason,
|
|
398
|
+
"width": image.width,
|
|
399
|
+
"height": image.height,
|
|
400
|
+
}
|
|
401
|
+
)
|
|
402
|
+
num_pages = len(images)
|
|
403
|
+
|
|
404
|
+
completed_at = datetime.now()
|
|
405
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
406
|
+
|
|
407
|
+
total_input = sum(u["input_tokens"] for u in page_usages)
|
|
408
|
+
total_output = sum(u["output_tokens"] for u in page_usages)
|
|
409
|
+
total_thinking = sum(u["thinking_tokens"] for u in page_usages)
|
|
410
|
+
total_all = sum(u["total_tokens"] for u in page_usages)
|
|
411
|
+
|
|
412
|
+
input_rate, output_rate = self._get_pricing()
|
|
413
|
+
cost = (total_input * input_rate + (total_output + total_thinking) * output_rate) / 1_000_000
|
|
414
|
+
|
|
415
|
+
config_info: dict[str, Any] = {
|
|
416
|
+
"dpi": self._dpi,
|
|
417
|
+
"max_tokens": self._max_tokens,
|
|
418
|
+
"region": self._region,
|
|
419
|
+
}
|
|
420
|
+
if self._reasoning_effort is not None:
|
|
421
|
+
config_info["reasoning_effort"] = self._reasoning_effort
|
|
422
|
+
if self._temperature is not None:
|
|
423
|
+
config_info["temperature"] = self._temperature
|
|
424
|
+
if self._top_p is not None:
|
|
425
|
+
config_info["top_p"] = self._top_p
|
|
426
|
+
|
|
427
|
+
raw_output = {
|
|
428
|
+
"pages": pages,
|
|
429
|
+
"num_pages": num_pages,
|
|
430
|
+
"model": self._model,
|
|
431
|
+
"config": config_info,
|
|
432
|
+
"input_tokens": total_input,
|
|
433
|
+
"output_tokens": total_output,
|
|
434
|
+
"thinking_tokens": total_thinking,
|
|
435
|
+
"total_tokens": total_all,
|
|
436
|
+
"cost_usd": cost,
|
|
437
|
+
"cost_per_page_usd": cost / num_pages if num_pages > 0 else 0.0,
|
|
438
|
+
"input_tokens_per_page": total_input / num_pages if num_pages > 0 else 0.0,
|
|
439
|
+
"output_tokens_per_page": total_output / num_pages if num_pages > 0 else 0.0,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return RawInferenceResult(
|
|
443
|
+
request=request,
|
|
444
|
+
pipeline=pipeline,
|
|
445
|
+
pipeline_name=pipeline.pipeline_name,
|
|
446
|
+
product_type=request.product_type,
|
|
447
|
+
raw_output=raw_output,
|
|
448
|
+
started_at=started_at,
|
|
449
|
+
completed_at=completed_at,
|
|
450
|
+
latency_in_ms=latency_ms,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
except (ProviderPermanentError, ProviderRateLimitError, ProviderTransientError, ProviderConfigError):
|
|
454
|
+
raise
|
|
455
|
+
except Exception as e:
|
|
456
|
+
raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
|
|
457
|
+
|
|
458
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
459
|
+
"""
|
|
460
|
+
Normalize raw inference result to produce ParseOutput.
|
|
461
|
+
|
|
462
|
+
:param raw_result: Raw inference result from run_inference()
|
|
463
|
+
:return: Inference result with both raw and normalized outputs
|
|
464
|
+
"""
|
|
465
|
+
if raw_result.product_type != ProductType.PARSE:
|
|
466
|
+
raise ProviderPermanentError(
|
|
467
|
+
f"AmazonNovaProvider only supports PARSE product type, got {raw_result.product_type}"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
pages: list[PageIR] = []
|
|
471
|
+
page_markdowns: list[str] = []
|
|
472
|
+
layout_pages: list[ParseLayoutPageIR] = []
|
|
473
|
+
|
|
474
|
+
for page_data in raw_result.raw_output.get("pages", []):
|
|
475
|
+
page_index = page_data.get("page_index", 0)
|
|
476
|
+
|
|
477
|
+
items = page_data.get("items", [])
|
|
478
|
+
image_width = page_data.get("width", 0)
|
|
479
|
+
image_height = page_data.get("height", 0)
|
|
480
|
+
markdown = items_to_markdown(items)
|
|
481
|
+
layout_pages.extend(
|
|
482
|
+
build_layout_pages(
|
|
483
|
+
items,
|
|
484
|
+
image_width,
|
|
485
|
+
image_height,
|
|
486
|
+
markdown,
|
|
487
|
+
page_number=page_index + 1,
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
pages.append(PageIR(page_index=page_index, markdown=markdown))
|
|
492
|
+
page_markdowns.append(markdown)
|
|
493
|
+
|
|
494
|
+
pages.sort(key=lambda p: p.page_index)
|
|
495
|
+
full_markdown = "\n\n".join(page_markdowns)
|
|
496
|
+
|
|
497
|
+
output = ParseOutput(
|
|
498
|
+
task_type="parse",
|
|
499
|
+
example_id=raw_result.request.example_id,
|
|
500
|
+
pipeline_name=raw_result.pipeline_name,
|
|
501
|
+
pages=pages,
|
|
502
|
+
markdown=full_markdown,
|
|
503
|
+
layout_pages=layout_pages,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
return InferenceResult(
|
|
507
|
+
request=raw_result.request,
|
|
508
|
+
pipeline_name=raw_result.pipeline_name,
|
|
509
|
+
product_type=raw_result.product_type,
|
|
510
|
+
raw_output=raw_result.raw_output,
|
|
511
|
+
output=output,
|
|
512
|
+
started_at=raw_result.started_at,
|
|
513
|
+
completed_at=raw_result.completed_at,
|
|
514
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
515
|
+
)
|