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,851 @@
|
|
|
1
|
+
"""Provider for Extend AI EXTRACT using the official Python SDK.
|
|
2
|
+
|
|
3
|
+
Based on Extend AI documentation: https://docs.extend.ai/developers/sd-ks
|
|
4
|
+
SDK: pip install extend-ai
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from parse_bench.inference.providers.base import (
|
|
16
|
+
Provider,
|
|
17
|
+
ProviderConfigError,
|
|
18
|
+
ProviderError,
|
|
19
|
+
ProviderPermanentError,
|
|
20
|
+
ProviderRateLimitError,
|
|
21
|
+
ProviderTransientError,
|
|
22
|
+
)
|
|
23
|
+
from parse_bench.inference.providers.extract.citations import extract_extend_field_citations
|
|
24
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
25
|
+
from parse_bench.schemas.pipeline import PipelineSpec
|
|
26
|
+
from parse_bench.schemas.pipeline_io import (
|
|
27
|
+
InferenceRequest,
|
|
28
|
+
InferenceResult,
|
|
29
|
+
RawInferenceResult,
|
|
30
|
+
)
|
|
31
|
+
from parse_bench.schemas.product import ProductType
|
|
32
|
+
|
|
33
|
+
_Extend: Any = None
|
|
34
|
+
_ApiError: Any = Exception
|
|
35
|
+
try:
|
|
36
|
+
from extend_ai import Extend as _ImportedExtend
|
|
37
|
+
from extend_ai.core.api_error import ApiError as _ImportedApiError
|
|
38
|
+
|
|
39
|
+
_Extend = _ImportedExtend
|
|
40
|
+
_ApiError = _ImportedApiError
|
|
41
|
+
_HAS_EXTEND_AI = True
|
|
42
|
+
except ImportError:
|
|
43
|
+
_HAS_EXTEND_AI = False
|
|
44
|
+
|
|
45
|
+
Extend: Any = _Extend
|
|
46
|
+
ApiError: Any = _ApiError
|
|
47
|
+
|
|
48
|
+
# Extend bills file parsing separately from document processing for older
|
|
49
|
+
# response payloads where processor_run.usage.credits covers extraction only.
|
|
50
|
+
# Newer payloads can include usage.totalCredits, which is the authoritative
|
|
51
|
+
# roll-up for work triggered by the run. Override the parse estimate per
|
|
52
|
+
# pipeline with `parse_credits_per_page` when totalCredits is absent.
|
|
53
|
+
# Source: https://docs.extend.ai/general/how-credits-work
|
|
54
|
+
DEFAULT_PARSE_CREDITS_PER_PAGE = 2.0
|
|
55
|
+
|
|
56
|
+
# USD per Extend credit at Pay-As-You-Go list pricing. Extend doesn't publish a
|
|
57
|
+
# direct per-credit rate, so this is derived from their published parse pricing:
|
|
58
|
+
# $0.025/page at 2 credits/page = $0.0125/credit. Hardcoded (like llamaextract's
|
|
59
|
+
# _USD_PER_CREDIT) so cost_usd / cost_per_page_usd always populate and the
|
|
60
|
+
# dashboard cost columns render. Update here if Extend's list pricing changes.
|
|
61
|
+
# Source: https://www.extend.ai/pricing
|
|
62
|
+
USD_PER_CREDIT = 0.0125
|
|
63
|
+
|
|
64
|
+
# JSON Schema properties not supported by Extend AI
|
|
65
|
+
UNSUPPORTED_SCHEMA_PROPERTIES = {
|
|
66
|
+
"$defs",
|
|
67
|
+
"$schema",
|
|
68
|
+
"pattern",
|
|
69
|
+
"not",
|
|
70
|
+
"allOf",
|
|
71
|
+
"anyOf",
|
|
72
|
+
"oneOf",
|
|
73
|
+
"if",
|
|
74
|
+
"then",
|
|
75
|
+
"else",
|
|
76
|
+
"minLength",
|
|
77
|
+
"maxLength",
|
|
78
|
+
"minimum",
|
|
79
|
+
"maximum",
|
|
80
|
+
"exclusiveMinimum",
|
|
81
|
+
"exclusiveMaximum",
|
|
82
|
+
"multipleOf",
|
|
83
|
+
"minItems",
|
|
84
|
+
"maxItems",
|
|
85
|
+
"uniqueItems",
|
|
86
|
+
"minProperties",
|
|
87
|
+
"maxProperties",
|
|
88
|
+
"patternProperties",
|
|
89
|
+
"format",
|
|
90
|
+
"const",
|
|
91
|
+
"contentMediaType",
|
|
92
|
+
"contentEncoding",
|
|
93
|
+
"default",
|
|
94
|
+
"definitions",
|
|
95
|
+
"title",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Property names Extend AI reserves for internal use and rejects at processor
|
|
99
|
+
# creation ("Field key '<name>' is reserved for internal use"). These are legal
|
|
100
|
+
# JSON Schema keys accepted by every other provider, so we rename them to a
|
|
101
|
+
# collision-free alias only in the schema submitted to Extend and restore the
|
|
102
|
+
# original name in the result -- the shared dataset schema/GT are never touched.
|
|
103
|
+
RESERVED_PROPERTY_NAMES = frozenset({"id"})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_extract_product_type(value: Any) -> bool:
|
|
107
|
+
extract_type = getattr(ProductType, "EXTRACT", None)
|
|
108
|
+
if extract_type is not None and value == extract_type:
|
|
109
|
+
return True
|
|
110
|
+
return bool(getattr(value, "value", value) == "extract")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _extract_output_cls() -> type[Any]:
|
|
114
|
+
from parse_bench.schemas.extract_output import ExtractOutput
|
|
115
|
+
|
|
116
|
+
return ExtractOutput
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _adapt_schema_for_extend(
|
|
120
|
+
schema: dict[str, Any],
|
|
121
|
+
) -> tuple[dict[str, Any], dict[str, list[str]], dict[str, dict[str, str]]]:
|
|
122
|
+
"""
|
|
123
|
+
Adapt a JSON schema for Extend AI compatibility.
|
|
124
|
+
|
|
125
|
+
Extend AI has limited JSON Schema support:
|
|
126
|
+
1. Array items must have type "object" (no primitive arrays like string[])
|
|
127
|
+
2. Object schemas must declare at least one property (no empty-object items)
|
|
128
|
+
3. A handful of property names ("id", ...) are reserved
|
|
129
|
+
4. Many advanced keywords (pattern, not, allOf, etc.) are not supported
|
|
130
|
+
|
|
131
|
+
This adapter:
|
|
132
|
+
- Wraps primitive array items in objects with a "value" property
|
|
133
|
+
- Treats an array whose items are an empty object ({} == "return an empty
|
|
134
|
+
array") as a primitive string array, so Extend accepts it
|
|
135
|
+
- Renames reserved property names to a collision-free alias
|
|
136
|
+
- Strips unsupported schema properties
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
tuple: (adapted_schema, primitive_array_paths, renamed_props) where
|
|
140
|
+
primitive_array_paths maps array JSON paths to the primitive
|
|
141
|
+
types that were wrapped, and renamed_props maps each parent
|
|
142
|
+
object's JSON path to an {alias: original_name} table.
|
|
143
|
+
"""
|
|
144
|
+
primitive_array_paths: dict[str, list[str]] = {}
|
|
145
|
+
renamed_props: dict[str, dict[str, str]] = {}
|
|
146
|
+
|
|
147
|
+
def alias_for(name: str, siblings: dict[str, Any]) -> str:
|
|
148
|
+
"""Pick a non-reserved alias that does not collide with a sibling key."""
|
|
149
|
+
candidate = f"{name}_field"
|
|
150
|
+
while candidate in siblings or candidate in RESERVED_PROPERTY_NAMES:
|
|
151
|
+
candidate = f"{candidate}_"
|
|
152
|
+
return candidate
|
|
153
|
+
|
|
154
|
+
def resolve_json_pointer(ref: str) -> dict[str, Any] | None:
|
|
155
|
+
if not ref.startswith("#/"):
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
current: Any = schema
|
|
159
|
+
for raw_part in ref[2:].split("/"):
|
|
160
|
+
part = raw_part.replace("~1", "/").replace("~0", "~")
|
|
161
|
+
if not isinstance(current, dict):
|
|
162
|
+
return None
|
|
163
|
+
current = current.get(part)
|
|
164
|
+
|
|
165
|
+
return current if isinstance(current, dict) else None
|
|
166
|
+
|
|
167
|
+
def is_null_schema(node: Any) -> bool:
|
|
168
|
+
return isinstance(node, dict) and node.get("type") == "null"
|
|
169
|
+
|
|
170
|
+
def normalize_nullable_schema(node: dict[str, Any]) -> dict[str, Any]:
|
|
171
|
+
"""Collapse nullable unions to the non-null schema shape Extend accepts."""
|
|
172
|
+
node_type = node.get("type")
|
|
173
|
+
if isinstance(node_type, list):
|
|
174
|
+
non_null_types = [value for value in node_type if value != "null"]
|
|
175
|
+
if len(non_null_types) == 1:
|
|
176
|
+
return {**node, "type": non_null_types[0]}
|
|
177
|
+
|
|
178
|
+
for union_key in ("anyOf", "oneOf"):
|
|
179
|
+
options = node.get(union_key)
|
|
180
|
+
if not isinstance(options, list):
|
|
181
|
+
continue
|
|
182
|
+
non_null_options = [option for option in options if not is_null_schema(option)]
|
|
183
|
+
if len(non_null_options) != 1 or len(non_null_options) == len(options):
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
result = dict(non_null_options[0])
|
|
187
|
+
for key, value in node.items():
|
|
188
|
+
if key != union_key and key not in result:
|
|
189
|
+
result[key] = value
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
return node
|
|
193
|
+
|
|
194
|
+
def resolve_ref_node(node: dict[str, Any]) -> dict[str, Any]:
|
|
195
|
+
ref = node.get("$ref")
|
|
196
|
+
if isinstance(ref, str):
|
|
197
|
+
resolved = resolve_json_pointer(ref)
|
|
198
|
+
if resolved is not None:
|
|
199
|
+
merged = dict(resolved)
|
|
200
|
+
for key, value in node.items():
|
|
201
|
+
if key != "$ref":
|
|
202
|
+
merged[key] = value
|
|
203
|
+
return merged
|
|
204
|
+
return node
|
|
205
|
+
|
|
206
|
+
def adapt_node(node: dict[str, Any], path: str = "") -> dict[str, Any]:
|
|
207
|
+
if not isinstance(node, dict):
|
|
208
|
+
return node
|
|
209
|
+
|
|
210
|
+
node = resolve_ref_node(node)
|
|
211
|
+
node = normalize_nullable_schema(node)
|
|
212
|
+
node = resolve_ref_node(node)
|
|
213
|
+
result: dict[str, Any] = {}
|
|
214
|
+
node_type = node.get("type")
|
|
215
|
+
|
|
216
|
+
for key, value in node.items():
|
|
217
|
+
# Skip unsupported properties
|
|
218
|
+
if key in UNSUPPORTED_SCHEMA_PROPERTIES:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if key == "properties" and isinstance(value, dict):
|
|
222
|
+
# Recurse into properties, renaming any reserved key. The child
|
|
223
|
+
# path keeps the ORIGINAL name so primitive_array_paths and the
|
|
224
|
+
# result adapter stay aligned; only the emitted key is aliased.
|
|
225
|
+
adapted_props: dict[str, Any] = {}
|
|
226
|
+
for prop_name, prop_schema in value.items():
|
|
227
|
+
child_path = f"{path}.{prop_name}" if path else prop_name
|
|
228
|
+
out_name = prop_name
|
|
229
|
+
if prop_name in RESERVED_PROPERTY_NAMES:
|
|
230
|
+
out_name = alias_for(prop_name, value)
|
|
231
|
+
renamed_props.setdefault(path, {})[out_name] = prop_name
|
|
232
|
+
adapted_props[out_name] = adapt_node(prop_schema, child_path)
|
|
233
|
+
result["properties"] = adapted_props
|
|
234
|
+
elif key == "items" and node_type == "array":
|
|
235
|
+
# Handle array items
|
|
236
|
+
if isinstance(value, dict):
|
|
237
|
+
resolved_items = resolve_ref_node(value)
|
|
238
|
+
items_type = resolved_items.get("type")
|
|
239
|
+
is_empty_object = items_type == "object" and not resolved_items.get("properties")
|
|
240
|
+
# A primitive array, or an empty-object array (a field whose
|
|
241
|
+
# values are never populated -- GT is always []). Extend
|
|
242
|
+
# rejects both, so wrap them in an object with a "value" key.
|
|
243
|
+
if items_type in ("string", "number", "integer", "boolean") or is_empty_object:
|
|
244
|
+
wrapped_type: str = "string" if is_empty_object else str(items_type)
|
|
245
|
+
primitive_array_paths[path] = [wrapped_type]
|
|
246
|
+
result["items"] = {
|
|
247
|
+
"type": "object",
|
|
248
|
+
"properties": {"value": {"type": wrapped_type}},
|
|
249
|
+
}
|
|
250
|
+
else:
|
|
251
|
+
# Recurse into object items
|
|
252
|
+
result["items"] = adapt_node(value, f"{path}[items]")
|
|
253
|
+
else:
|
|
254
|
+
result["items"] = value
|
|
255
|
+
elif isinstance(value, dict):
|
|
256
|
+
result[key] = adapt_node(value, path)
|
|
257
|
+
elif isinstance(value, list):
|
|
258
|
+
result[key] = [adapt_node(item, path) if isinstance(item, dict) else item for item in value]
|
|
259
|
+
else:
|
|
260
|
+
result[key] = value
|
|
261
|
+
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
adapted = adapt_node(schema)
|
|
265
|
+
return adapted, primitive_array_paths, renamed_props
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _adapt_result_from_extend(
|
|
269
|
+
data: Any,
|
|
270
|
+
primitive_array_paths: dict[str, list[str]],
|
|
271
|
+
renamed_props: dict[str, dict[str, str]] | None = None,
|
|
272
|
+
path: str = "",
|
|
273
|
+
) -> Any:
|
|
274
|
+
"""
|
|
275
|
+
Adapt extraction results back to match the original schema.
|
|
276
|
+
|
|
277
|
+
Unwraps primitive values that were wrapped in objects for Extend AI
|
|
278
|
+
compatibility, and restores any reserved property name that was aliased in
|
|
279
|
+
the submitted schema.
|
|
280
|
+
"""
|
|
281
|
+
renamed_props = renamed_props or {}
|
|
282
|
+
|
|
283
|
+
if data is None:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
if isinstance(data, dict):
|
|
287
|
+
result = {}
|
|
288
|
+
rename_here = renamed_props.get(path, {})
|
|
289
|
+
for key, value in data.items():
|
|
290
|
+
original_key = rename_here.get(key, key)
|
|
291
|
+
current_path = f"{path}.{original_key}" if path else f"{original_key}"
|
|
292
|
+
result[original_key] = _adapt_result_from_extend(value, primitive_array_paths, renamed_props, current_path)
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
if isinstance(data, list):
|
|
296
|
+
# Check if this array path had primitive items that were wrapped
|
|
297
|
+
if path in primitive_array_paths:
|
|
298
|
+
# Unwrap the "value" from each object
|
|
299
|
+
return [item.get("value") if isinstance(item, dict) else item for item in data]
|
|
300
|
+
else:
|
|
301
|
+
# Recurse into array items
|
|
302
|
+
return [
|
|
303
|
+
_adapt_result_from_extend(item, primitive_array_paths, renamed_props, f"{path}[items]") for item in data
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
return data
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _coerce_nonnegative_float(value: Any) -> float | None:
|
|
310
|
+
if isinstance(value, (int, float)) and value >= 0:
|
|
311
|
+
return float(value)
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _attach_cost_stats(raw_output: dict[str, Any], pipeline_config: dict[str, Any]) -> None:
|
|
316
|
+
"""Surface Extend billing data as top-level raw_output keys.
|
|
317
|
+
|
|
318
|
+
build_operational_stats picks up `credits_used`, `parse_credits`,
|
|
319
|
+
`total_credits`, `cost_usd` and `cost_per_page_usd` from raw_output, and
|
|
320
|
+
`num_pages` additionally enables per-page latency. Called from both
|
|
321
|
+
run_inference (so .raw.json is self-describing) and normalize (so
|
|
322
|
+
`bench inference renormalize` backfills stats on older runs).
|
|
323
|
+
"""
|
|
324
|
+
processor_run = raw_output.get("processor_run") or {}
|
|
325
|
+
|
|
326
|
+
num_pages = 0
|
|
327
|
+
for file_info in processor_run.get("files") or []:
|
|
328
|
+
page_count = ((file_info or {}).get("metadata") or {}).get("page_count")
|
|
329
|
+
if isinstance(page_count, (int, float)):
|
|
330
|
+
num_pages += int(page_count)
|
|
331
|
+
if num_pages:
|
|
332
|
+
raw_output["num_pages"] = num_pages
|
|
333
|
+
|
|
334
|
+
# Doc-processing (extraction) credits, measured by the API. Absent for
|
|
335
|
+
# runs created before 2025-10-07 or on legacy billing.
|
|
336
|
+
usage = processor_run.get("usage") or {}
|
|
337
|
+
credits_used = _coerce_nonnegative_float(usage.get("credits"))
|
|
338
|
+
total_credits_from_usage = _coerce_nonnegative_float(usage.get("totalCredits"))
|
|
339
|
+
if total_credits_from_usage is None:
|
|
340
|
+
total_credits_from_usage = _coerce_nonnegative_float(usage.get("total_credits"))
|
|
341
|
+
if credits_used is None and total_credits_from_usage is None:
|
|
342
|
+
return
|
|
343
|
+
if credits_used is not None:
|
|
344
|
+
raw_output["credits_used"] = credits_used
|
|
345
|
+
|
|
346
|
+
if total_credits_from_usage is not None:
|
|
347
|
+
total_credits = total_credits_from_usage
|
|
348
|
+
if credits_used is not None:
|
|
349
|
+
raw_output["parse_credits"] = max(0.0, total_credits - credits_used)
|
|
350
|
+
else:
|
|
351
|
+
parse_credits_per_page = float(pipeline_config.get("parse_credits_per_page", DEFAULT_PARSE_CREDITS_PER_PAGE))
|
|
352
|
+
parse_credits = parse_credits_per_page * num_pages
|
|
353
|
+
raw_output["parse_credits"] = parse_credits
|
|
354
|
+
total_credits = float(credits_used or 0.0) + parse_credits
|
|
355
|
+
raw_output["total_credits"] = total_credits
|
|
356
|
+
|
|
357
|
+
# USD conversion at PAYG list pricing so the dashboard cost columns populate.
|
|
358
|
+
cost_usd = total_credits * USD_PER_CREDIT
|
|
359
|
+
raw_output["cost_usd"] = cost_usd
|
|
360
|
+
if num_pages:
|
|
361
|
+
raw_output["credits_per_page"] = total_credits / num_pages
|
|
362
|
+
raw_output["cost_per_page_usd"] = cost_usd / num_pages
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@register_provider("extend")
|
|
366
|
+
class ExtendProvider(Provider):
|
|
367
|
+
"""
|
|
368
|
+
Provider for Extend AI document extraction using the official SDK.
|
|
369
|
+
|
|
370
|
+
This provider uses the extend-ai Python SDK for extraction tasks.
|
|
371
|
+
SDK Documentation: https://docs.extend.ai/developers/sd-ks
|
|
372
|
+
|
|
373
|
+
Workflow:
|
|
374
|
+
1. Upload file via client.file.upload()
|
|
375
|
+
2. Create processor with schema via client.processor.create() (cached per schema hash)
|
|
376
|
+
3. Run processor via client.processor_run.create() with sync=True
|
|
377
|
+
|
|
378
|
+
Note: This provider adapts schemas to handle Extend AI's limited JSON Schema support:
|
|
379
|
+
- Primitive arrays (string[], number[]) are wrapped in objects
|
|
380
|
+
- Unsupported properties (pattern, not, allOf, etc.) are stripped
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(
|
|
384
|
+
self,
|
|
385
|
+
provider_name: str,
|
|
386
|
+
base_config: dict[str, Any] | None = None,
|
|
387
|
+
):
|
|
388
|
+
"""
|
|
389
|
+
Initialize the provider.
|
|
390
|
+
|
|
391
|
+
:param provider_name: Name of the provider
|
|
392
|
+
:param base_config: Optional configuration with:
|
|
393
|
+
- `api_key`: Extend AI API key (defaults to EXTEND_API_KEY env var)
|
|
394
|
+
- `base_url`: Optional base URL for different deployments
|
|
395
|
+
(default: https://api.extend.ai, alternatives: https://api.us2.extend.app,
|
|
396
|
+
https://api.eu1.extend.ai)
|
|
397
|
+
- `processor_name_prefix`: Prefix for processor names (default: "bench_")
|
|
398
|
+
- `timeout`: Request timeout in seconds (default: 300)
|
|
399
|
+
"""
|
|
400
|
+
super().__init__(provider_name, base_config)
|
|
401
|
+
|
|
402
|
+
if not _HAS_EXTEND_AI or Extend is None:
|
|
403
|
+
raise ProviderConfigError("ExtendProvider requires extend-ai. Install it with: pip install extend-ai")
|
|
404
|
+
|
|
405
|
+
# Get API key
|
|
406
|
+
api_key = self.base_config.get("api_key") or os.getenv("EXTEND_API_KEY")
|
|
407
|
+
if not api_key:
|
|
408
|
+
raise ProviderConfigError(
|
|
409
|
+
"Extend AI API key is required. Set EXTEND_API_KEY environment variable or pass api_key in base_config."
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Configuration
|
|
413
|
+
self._processor_name_prefix = self.base_config.get("processor_name_prefix", "bench_")
|
|
414
|
+
timeout = self.base_config.get("timeout", 300)
|
|
415
|
+
|
|
416
|
+
# Initialize the Extend client
|
|
417
|
+
client_kwargs: dict[str, Any] = {
|
|
418
|
+
"token": api_key,
|
|
419
|
+
"timeout": float(timeout),
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
# Optional base URL for different deployments (US2, EU1, etc.)
|
|
423
|
+
base_url = self.base_config.get("base_url")
|
|
424
|
+
if base_url:
|
|
425
|
+
client_kwargs["base_url"] = base_url
|
|
426
|
+
|
|
427
|
+
self._client = Extend(**client_kwargs)
|
|
428
|
+
|
|
429
|
+
# Cache for processor IDs by schema hash (thread-safe)
|
|
430
|
+
self._processor_cache: dict[str, str] = {}
|
|
431
|
+
self._processor_cache_lock = threading.Lock()
|
|
432
|
+
|
|
433
|
+
def _get_config_hash(self, config: dict[str, Any]) -> str:
|
|
434
|
+
"""Get a deterministic hash of a config for caching processors."""
|
|
435
|
+
config_str = json.dumps(config, sort_keys=True)
|
|
436
|
+
return hashlib.sha256(config_str.encode()).hexdigest()[:16]
|
|
437
|
+
|
|
438
|
+
def _handle_api_error(self, e: ApiError, context: str) -> None:
|
|
439
|
+
"""Convert SDK ApiError to appropriate ProviderError."""
|
|
440
|
+
status_code = getattr(e, "status_code", None)
|
|
441
|
+
error_body = getattr(e, "body", str(e))
|
|
442
|
+
|
|
443
|
+
if status_code == 429:
|
|
444
|
+
raise ProviderRateLimitError(f"Rate limit exceeded during {context}: {error_body}")
|
|
445
|
+
elif status_code in (502, 503, 504):
|
|
446
|
+
raise ProviderTransientError(f"Transient error during {context}: {status_code} - {error_body}")
|
|
447
|
+
elif status_code and status_code >= 400:
|
|
448
|
+
raise ProviderPermanentError(f"Error during {context}: {status_code} - {error_body}")
|
|
449
|
+
else:
|
|
450
|
+
raise ProviderPermanentError(f"API error during {context}: {error_body}")
|
|
451
|
+
|
|
452
|
+
def _upload_file(self, file_path: str) -> str:
|
|
453
|
+
"""
|
|
454
|
+
Upload a file to Extend AI.
|
|
455
|
+
|
|
456
|
+
:param file_path: Path to the file to upload
|
|
457
|
+
:return: File ID from Extend AI
|
|
458
|
+
:raises ProviderError: For any upload errors
|
|
459
|
+
"""
|
|
460
|
+
try:
|
|
461
|
+
with open(file_path, "rb") as f:
|
|
462
|
+
upload_response = self._client.files.upload(file=f)
|
|
463
|
+
|
|
464
|
+
# Extract file ID from response
|
|
465
|
+
if hasattr(upload_response, "id"):
|
|
466
|
+
return str(upload_response.id)
|
|
467
|
+
elif hasattr(upload_response, "file") and hasattr(upload_response.file, "id"):
|
|
468
|
+
return str(upload_response.file.id)
|
|
469
|
+
elif isinstance(upload_response, dict):
|
|
470
|
+
file_data = upload_response.get("file", upload_response)
|
|
471
|
+
file_id = file_data.get("id") or file_data.get("fileId")
|
|
472
|
+
if file_id:
|
|
473
|
+
return str(file_id)
|
|
474
|
+
|
|
475
|
+
raise ProviderPermanentError(f"No file ID in upload response: {upload_response}")
|
|
476
|
+
|
|
477
|
+
except ApiError as e:
|
|
478
|
+
self._handle_api_error(e, "file upload")
|
|
479
|
+
raise # Should not reach here, but satisfies type checker
|
|
480
|
+
except Exception as e:
|
|
481
|
+
error_str = str(e).lower()
|
|
482
|
+
if any(kw in error_str for kw in ["timeout", "timed out", "connection", "network", "readtimeout"]):
|
|
483
|
+
raise ProviderTransientError(f"Transient error during file upload: {e}") from e
|
|
484
|
+
raise ProviderPermanentError(f"Unexpected error during file upload: {e}") from e
|
|
485
|
+
|
|
486
|
+
def _build_processor_config(self, schema: dict[str, Any], pipeline_config: dict[str, Any]) -> dict[str, Any]:
|
|
487
|
+
"""
|
|
488
|
+
Build the processor config by merging schema with pipeline config options.
|
|
489
|
+
|
|
490
|
+
:param schema: JSON schema for extraction
|
|
491
|
+
:param pipeline_config: Pipeline configuration options
|
|
492
|
+
:return: Complete processor config
|
|
493
|
+
"""
|
|
494
|
+
config: dict[str, Any] = {
|
|
495
|
+
"type": "EXTRACT",
|
|
496
|
+
"schema": schema,
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
# Add baseProcessor if specified (e.g., "extraction_performance")
|
|
500
|
+
if "baseProcessor" in pipeline_config:
|
|
501
|
+
config["baseProcessor"] = pipeline_config["baseProcessor"]
|
|
502
|
+
|
|
503
|
+
# Add baseVersion if specified (e.g., "4.1.1")
|
|
504
|
+
if "baseVersion" in pipeline_config:
|
|
505
|
+
config["baseVersion"] = pipeline_config["baseVersion"]
|
|
506
|
+
|
|
507
|
+
# Add advancedOptions if specified
|
|
508
|
+
if "advancedOptions" in pipeline_config:
|
|
509
|
+
config["advancedOptions"] = pipeline_config["advancedOptions"]
|
|
510
|
+
|
|
511
|
+
return config
|
|
512
|
+
|
|
513
|
+
def _find_processor_by_name(self, name: str) -> str | None:
|
|
514
|
+
"""
|
|
515
|
+
Find an existing processor by name.
|
|
516
|
+
|
|
517
|
+
Handles pagination to search through all processors.
|
|
518
|
+
|
|
519
|
+
:param name: Name of the processor to find
|
|
520
|
+
:return: Processor ID if found, None otherwise
|
|
521
|
+
"""
|
|
522
|
+
try:
|
|
523
|
+
next_page_token: str | None = None
|
|
524
|
+
|
|
525
|
+
while True:
|
|
526
|
+
# List processors with pagination
|
|
527
|
+
if next_page_token:
|
|
528
|
+
list_response = self._client.processor.list(next_page_token=next_page_token)
|
|
529
|
+
else:
|
|
530
|
+
list_response = self._client.processor.list()
|
|
531
|
+
|
|
532
|
+
# Extract processors from response
|
|
533
|
+
processors: list[Any] = []
|
|
534
|
+
if hasattr(list_response, "processors"):
|
|
535
|
+
processors = list_response.processors or []
|
|
536
|
+
elif hasattr(list_response, "data"):
|
|
537
|
+
processors = list_response.data or []
|
|
538
|
+
elif isinstance(list_response, list):
|
|
539
|
+
processors = list_response
|
|
540
|
+
|
|
541
|
+
# Search for processor by name
|
|
542
|
+
for processor in processors:
|
|
543
|
+
proc_name = getattr(processor, "name", None)
|
|
544
|
+
if proc_name == name:
|
|
545
|
+
proc_id = getattr(processor, "id", None)
|
|
546
|
+
if proc_id:
|
|
547
|
+
return str(proc_id)
|
|
548
|
+
|
|
549
|
+
# Check for next page
|
|
550
|
+
next_page_token = getattr(list_response, "next_page_token", None)
|
|
551
|
+
if not next_page_token:
|
|
552
|
+
break
|
|
553
|
+
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
except Exception:
|
|
557
|
+
# If listing fails, return None and let creation handle it
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
def _create_processor(self, processor_config: dict[str, Any], config_hash: str) -> str:
|
|
561
|
+
"""
|
|
562
|
+
Create an extraction processor with the given config.
|
|
563
|
+
|
|
564
|
+
:param processor_config: Full processor configuration including schema
|
|
565
|
+
:param config_hash: Hash of the config for naming
|
|
566
|
+
:return: Processor ID
|
|
567
|
+
:raises ProviderError: For any creation errors
|
|
568
|
+
"""
|
|
569
|
+
processor_name = f"{self._processor_name_prefix}{config_hash}"
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
processor_response = self._client.processor.create(
|
|
573
|
+
name=processor_name,
|
|
574
|
+
type="EXTRACT", # type: ignore[arg-type]
|
|
575
|
+
config=processor_config, # type: ignore[arg-type]
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# Extract processor ID from response
|
|
579
|
+
# Response is ProcessorCreateResponse with a 'processor' attribute
|
|
580
|
+
if hasattr(processor_response, "processor"):
|
|
581
|
+
processor = processor_response.processor
|
|
582
|
+
if hasattr(processor, "id"):
|
|
583
|
+
return str(processor.id)
|
|
584
|
+
elif hasattr(processor_response, "id"):
|
|
585
|
+
return str(processor_response.id)
|
|
586
|
+
elif isinstance(processor_response, dict):
|
|
587
|
+
# Handle dict response
|
|
588
|
+
if "processor" in processor_response:
|
|
589
|
+
processor_id = processor_response["processor"].get("id")
|
|
590
|
+
else:
|
|
591
|
+
processor_id = processor_response.get("id") or processor_response.get("processorId")
|
|
592
|
+
if processor_id:
|
|
593
|
+
return str(processor_id)
|
|
594
|
+
|
|
595
|
+
raise ProviderPermanentError(f"No processor ID in creation response: {processor_response}")
|
|
596
|
+
|
|
597
|
+
except ApiError as e:
|
|
598
|
+
# Check if processor already exists
|
|
599
|
+
error_body = getattr(e, "body", {})
|
|
600
|
+
error_msg = ""
|
|
601
|
+
if isinstance(error_body, dict):
|
|
602
|
+
error_msg = error_body.get("error", "")
|
|
603
|
+
else:
|
|
604
|
+
error_msg = str(error_body)
|
|
605
|
+
|
|
606
|
+
if "already exists" in error_msg.lower():
|
|
607
|
+
# Try to find the existing processor
|
|
608
|
+
existing_id = self._find_processor_by_name(processor_name)
|
|
609
|
+
if existing_id:
|
|
610
|
+
return existing_id
|
|
611
|
+
|
|
612
|
+
self._handle_api_error(e, "processor creation")
|
|
613
|
+
raise # Should not reach here, but satisfies type checker
|
|
614
|
+
except Exception as e:
|
|
615
|
+
error_str = str(e).lower()
|
|
616
|
+
if any(kw in error_str for kw in ["timeout", "timed out", "connection", "network", "readtimeout"]):
|
|
617
|
+
raise ProviderTransientError(f"Transient error during processor creation: {e}") from e
|
|
618
|
+
raise ProviderPermanentError(f"Unexpected error during processor creation: {e}") from e
|
|
619
|
+
|
|
620
|
+
def _get_or_create_processor(self, processor_config: dict[str, Any]) -> str:
|
|
621
|
+
"""
|
|
622
|
+
Get existing processor ID or create a new one for the given config.
|
|
623
|
+
|
|
624
|
+
Thread-safe: uses locking to prevent concurrent creation of same processor.
|
|
625
|
+
|
|
626
|
+
:param processor_config: Full processor configuration including schema
|
|
627
|
+
:return: Processor ID
|
|
628
|
+
"""
|
|
629
|
+
config_hash = self._get_config_hash(processor_config)
|
|
630
|
+
processor_name = f"{self._processor_name_prefix}{config_hash}"
|
|
631
|
+
|
|
632
|
+
# Fast path: check cache without lock
|
|
633
|
+
if config_hash in self._processor_cache:
|
|
634
|
+
return self._processor_cache[config_hash]
|
|
635
|
+
|
|
636
|
+
# Slow path: acquire lock to prevent concurrent creation
|
|
637
|
+
with self._processor_cache_lock:
|
|
638
|
+
# Double-check after acquiring lock
|
|
639
|
+
if config_hash in self._processor_cache:
|
|
640
|
+
return self._processor_cache[config_hash]
|
|
641
|
+
|
|
642
|
+
# Check if processor already exists in Extend before creating
|
|
643
|
+
existing_id = self._find_processor_by_name(processor_name)
|
|
644
|
+
if existing_id:
|
|
645
|
+
self._processor_cache[config_hash] = existing_id
|
|
646
|
+
return existing_id
|
|
647
|
+
|
|
648
|
+
# Create new processor
|
|
649
|
+
processor_id = self._create_processor(processor_config, config_hash)
|
|
650
|
+
self._processor_cache[config_hash] = processor_id
|
|
651
|
+
return processor_id
|
|
652
|
+
|
|
653
|
+
def _run_processor(self, processor_id: str, file_id: str) -> dict[str, Any]:
|
|
654
|
+
"""
|
|
655
|
+
Run a processor on a file synchronously.
|
|
656
|
+
|
|
657
|
+
:param processor_id: ID of the processor to run
|
|
658
|
+
:param file_id: ID of the uploaded file
|
|
659
|
+
:return: Raw response from the processor run
|
|
660
|
+
:raises ProviderError: For any run errors
|
|
661
|
+
"""
|
|
662
|
+
try:
|
|
663
|
+
run_response = self._client.processor_run.create(
|
|
664
|
+
processor_id=processor_id,
|
|
665
|
+
file={"fileId": file_id}, # type: ignore[arg-type]
|
|
666
|
+
sync=True, # Synchronous processing - waits for completion
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# Convert response to dict for storage
|
|
670
|
+
if hasattr(run_response, "model_dump"):
|
|
671
|
+
return cast(dict[str, Any], run_response.model_dump())
|
|
672
|
+
elif hasattr(run_response, "dict"):
|
|
673
|
+
return cast(dict[str, Any], run_response.dict())
|
|
674
|
+
elif isinstance(run_response, dict):
|
|
675
|
+
return run_response
|
|
676
|
+
else:
|
|
677
|
+
# Try to extract attributes manually
|
|
678
|
+
result: dict[str, Any] = {}
|
|
679
|
+
for attr in [
|
|
680
|
+
"id",
|
|
681
|
+
"status",
|
|
682
|
+
"output",
|
|
683
|
+
"extracted_data",
|
|
684
|
+
"extractedData",
|
|
685
|
+
"data",
|
|
686
|
+
"result",
|
|
687
|
+
"error",
|
|
688
|
+
"processorId",
|
|
689
|
+
"fileId",
|
|
690
|
+
]:
|
|
691
|
+
if hasattr(run_response, attr):
|
|
692
|
+
value = getattr(run_response, attr)
|
|
693
|
+
if not callable(value):
|
|
694
|
+
result[attr] = value
|
|
695
|
+
return result
|
|
696
|
+
|
|
697
|
+
except ApiError as e:
|
|
698
|
+
self._handle_api_error(e, "processor run")
|
|
699
|
+
raise # Should not reach here, but satisfies type checker
|
|
700
|
+
except Exception as e:
|
|
701
|
+
error_str = str(e).lower()
|
|
702
|
+
if any(kw in error_str for kw in ["timeout", "timed out", "connection", "network", "readtimeout"]):
|
|
703
|
+
raise ProviderTransientError(f"Transient error during processor run: {e}") from e
|
|
704
|
+
raise ProviderPermanentError(f"Unexpected error during processor run: {e}") from e
|
|
705
|
+
|
|
706
|
+
def _extract_document(
|
|
707
|
+
self,
|
|
708
|
+
file_path: str,
|
|
709
|
+
schema: dict[str, Any],
|
|
710
|
+
pipeline_config: dict[str, Any],
|
|
711
|
+
) -> dict[str, Any]:
|
|
712
|
+
"""
|
|
713
|
+
Extract data from a document using Extend AI.
|
|
714
|
+
|
|
715
|
+
:param file_path: Path to the document file
|
|
716
|
+
:param schema: JSON schema for extraction
|
|
717
|
+
:param pipeline_config: Pipeline configuration options
|
|
718
|
+
:return: Raw API response with extracted data
|
|
719
|
+
:raises ProviderError: For any extraction errors
|
|
720
|
+
"""
|
|
721
|
+
# Step 0: Adapt schema for Extend AI compatibility
|
|
722
|
+
adapted_schema, primitive_array_paths, renamed_props = _adapt_schema_for_extend(schema)
|
|
723
|
+
|
|
724
|
+
# Step 1: Upload file
|
|
725
|
+
file_id = self._upload_file(file_path)
|
|
726
|
+
|
|
727
|
+
# Step 2: Build processor config with adapted schema and pipeline options
|
|
728
|
+
processor_config = self._build_processor_config(adapted_schema, pipeline_config)
|
|
729
|
+
|
|
730
|
+
# Step 3: Get or create processor for this config
|
|
731
|
+
processor_id = self._get_or_create_processor(processor_config)
|
|
732
|
+
|
|
733
|
+
# Step 4: Run processor synchronously
|
|
734
|
+
result = self._run_processor(processor_id, file_id)
|
|
735
|
+
|
|
736
|
+
# Add metadata (including schema adaptation info for normalization)
|
|
737
|
+
result["_extend_metadata"] = {
|
|
738
|
+
"file_id": file_id,
|
|
739
|
+
"processor_id": processor_id,
|
|
740
|
+
"primitive_array_paths": primitive_array_paths,
|
|
741
|
+
"renamed_props": renamed_props,
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return result
|
|
745
|
+
|
|
746
|
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
|
747
|
+
"""
|
|
748
|
+
Run inference and return raw results.
|
|
749
|
+
|
|
750
|
+
:param pipeline: Pipeline specification
|
|
751
|
+
:param request: Inference request (must include schema_override for EXTRACT)
|
|
752
|
+
:return: Raw inference result
|
|
753
|
+
:raises ProviderError: For any provider-related failures
|
|
754
|
+
"""
|
|
755
|
+
if not _is_extract_product_type(request.product_type):
|
|
756
|
+
raise ProviderPermanentError(
|
|
757
|
+
f"ExtendProvider only supports EXTRACT product type, got {request.product_type}"
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
# Schema is required for extraction
|
|
761
|
+
if not request.schema_override:
|
|
762
|
+
raise ProviderPermanentError(
|
|
763
|
+
"schema_override is required for EXTRACT product type. "
|
|
764
|
+
"Provide a JSON schema in InferenceRequest.schema_override"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
started_at = datetime.now()
|
|
768
|
+
|
|
769
|
+
# Check if file exists
|
|
770
|
+
file_path = Path(request.source_file_path)
|
|
771
|
+
if not file_path.exists():
|
|
772
|
+
raise ProviderPermanentError(f"File not found: {file_path}")
|
|
773
|
+
|
|
774
|
+
try:
|
|
775
|
+
# Run extraction with pipeline config options
|
|
776
|
+
raw_output = self._extract_document(
|
|
777
|
+
file_path=str(file_path),
|
|
778
|
+
schema=request.schema_override,
|
|
779
|
+
pipeline_config=pipeline.config,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
completed_at = datetime.now()
|
|
783
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
784
|
+
|
|
785
|
+
_attach_cost_stats(raw_output, pipeline.config)
|
|
786
|
+
|
|
787
|
+
return RawInferenceResult(
|
|
788
|
+
request=request,
|
|
789
|
+
pipeline=pipeline,
|
|
790
|
+
pipeline_name=pipeline.pipeline_name,
|
|
791
|
+
product_type=request.product_type,
|
|
792
|
+
raw_output=raw_output,
|
|
793
|
+
started_at=started_at,
|
|
794
|
+
completed_at=completed_at,
|
|
795
|
+
latency_in_ms=latency_ms,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
except ProviderError:
|
|
799
|
+
# Preserve transient/rate-limit classification so the harness
|
|
800
|
+
# retries — wrapping these as permanent disabled all retry and
|
|
801
|
+
# turned vendor 429s into hard per-doc failures.
|
|
802
|
+
raise
|
|
803
|
+
except Exception as e:
|
|
804
|
+
raise ProviderPermanentError(f"Unexpected error during inference: {e}") from e
|
|
805
|
+
|
|
806
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
807
|
+
"""
|
|
808
|
+
Normalize raw inference result to produce ExtractOutput.
|
|
809
|
+
|
|
810
|
+
:param raw_result: Raw inference result from run_inference()
|
|
811
|
+
:return: Inference result with both raw and normalized outputs
|
|
812
|
+
:raises ProviderError: For any normalization failures
|
|
813
|
+
"""
|
|
814
|
+
if not _is_extract_product_type(raw_result.product_type):
|
|
815
|
+
raise ProviderPermanentError(
|
|
816
|
+
f"ExtendProvider only supports EXTRACT product type, got {raw_result.product_type}"
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
# Backfill cost stats for raw results recorded before cost tracking
|
|
820
|
+
# existed (renormalize path); no-op when run_inference already set them.
|
|
821
|
+
_attach_cost_stats(raw_result.raw_output, raw_result.pipeline.config)
|
|
822
|
+
|
|
823
|
+
# Extract the structured data from processor_run.output.value
|
|
824
|
+
extracted_data = raw_result.raw_output.get("processor_run", {}).get("output", {}).get("value", {})
|
|
825
|
+
|
|
826
|
+
# Adapt the result back to match the original schema
|
|
827
|
+
# (unwrap primitive arrays that were wrapped for Extend AI)
|
|
828
|
+
extend_metadata = raw_result.raw_output.get("_extend_metadata", {})
|
|
829
|
+
primitive_array_paths = extend_metadata.get("primitive_array_paths", {})
|
|
830
|
+
renamed_props = extend_metadata.get("renamed_props", {})
|
|
831
|
+
if primitive_array_paths or renamed_props:
|
|
832
|
+
extracted_data = _adapt_result_from_extend(extracted_data, primitive_array_paths, renamed_props)
|
|
833
|
+
|
|
834
|
+
output = _extract_output_cls()(
|
|
835
|
+
task_type="extract",
|
|
836
|
+
example_id=raw_result.request.example_id,
|
|
837
|
+
pipeline_name=raw_result.pipeline_name,
|
|
838
|
+
extracted_data=extracted_data,
|
|
839
|
+
field_citations=extract_extend_field_citations(raw_result.raw_output),
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
return InferenceResult(
|
|
843
|
+
request=raw_result.request,
|
|
844
|
+
pipeline_name=raw_result.pipeline_name,
|
|
845
|
+
product_type=raw_result.product_type,
|
|
846
|
+
raw_output=raw_result.raw_output,
|
|
847
|
+
output=output,
|
|
848
|
+
started_at=raw_result.started_at,
|
|
849
|
+
completed_at=raw_result.completed_at,
|
|
850
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
851
|
+
)
|