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,549 @@
|
|
|
1
|
+
"""Helpers for normalizing provider field citation bboxes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from parse_bench.schemas.extract_output import FieldCitation
|
|
10
|
+
else:
|
|
11
|
+
FieldCitation = Any
|
|
12
|
+
|
|
13
|
+
_STRUCTURAL_KEYS = {
|
|
14
|
+
"citation",
|
|
15
|
+
"citations",
|
|
16
|
+
"document_metadata",
|
|
17
|
+
"field_metadata",
|
|
18
|
+
"fields",
|
|
19
|
+
"metadata",
|
|
20
|
+
"page_metadata",
|
|
21
|
+
"properties",
|
|
22
|
+
"row_metadata",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _field_citation_cls() -> type[Any]:
|
|
27
|
+
from parse_bench.schemas.extract_output import FieldCitation as _FieldCitation
|
|
28
|
+
|
|
29
|
+
return _FieldCitation
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def extract_extend_field_citations(raw_output: Mapping[str, Any]) -> list[FieldCitation]:
|
|
33
|
+
"""Extract citations from Extend AI processor-run metadata."""
|
|
34
|
+
output = _as_mapping(_as_mapping(raw_output.get("processor_run")).get("output"))
|
|
35
|
+
metadata = _as_mapping(output.get("metadata"))
|
|
36
|
+
return _dedupe(_collect_field_map(metadata, source="extend"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_llamaextract_field_citations(metadata: Any, *, source: str) -> list[FieldCitation]:
|
|
40
|
+
"""Extract citations from LlamaExtract metadata in known and fallback shapes."""
|
|
41
|
+
metadata_map = _as_mapping(metadata)
|
|
42
|
+
if not metadata_map:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
citations: list[FieldCitation] = []
|
|
46
|
+
|
|
47
|
+
for key in ("field_metadata", "document_metadata", "fields"):
|
|
48
|
+
citations.extend(_collect_field_map(_as_mapping(metadata_map.get(key)), source=source))
|
|
49
|
+
|
|
50
|
+
for key in ("page_metadata", "row_metadata"):
|
|
51
|
+
entries = metadata_map.get(key)
|
|
52
|
+
if not isinstance(entries, Sequence) or isinstance(entries, (str, bytes, bytearray)):
|
|
53
|
+
continue
|
|
54
|
+
for entry in entries:
|
|
55
|
+
entry_map = _as_mapping(entry)
|
|
56
|
+
default_page = _extract_page(entry_map)
|
|
57
|
+
default_dimensions = _extract_dimensions(entry_map)
|
|
58
|
+
for field_key in ("field_metadata", "document_metadata", "fields"):
|
|
59
|
+
citations.extend(
|
|
60
|
+
_collect_field_map(
|
|
61
|
+
_as_mapping(entry_map.get(field_key)),
|
|
62
|
+
source=source,
|
|
63
|
+
default_page=default_page,
|
|
64
|
+
default_dimensions=default_dimensions,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
citations.extend(_collect_recursive(node=metadata_map, source=source, path=[]))
|
|
69
|
+
return _dedupe(citations)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _collect_field_map(
|
|
73
|
+
field_map: Mapping[str, Any],
|
|
74
|
+
*,
|
|
75
|
+
source: str,
|
|
76
|
+
default_page: int | None = None,
|
|
77
|
+
default_dimensions: tuple[float, float] | None = None,
|
|
78
|
+
) -> list[FieldCitation]:
|
|
79
|
+
citations: list[FieldCitation] = []
|
|
80
|
+
for field_path, node in field_map.items():
|
|
81
|
+
if field_path.startswith("_"):
|
|
82
|
+
continue
|
|
83
|
+
citations.extend(
|
|
84
|
+
_collect_node_citations(
|
|
85
|
+
field_path=field_path,
|
|
86
|
+
node=node,
|
|
87
|
+
source=source,
|
|
88
|
+
default_page=default_page,
|
|
89
|
+
default_dimensions=default_dimensions,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return citations
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _collect_node_citations(
|
|
96
|
+
*,
|
|
97
|
+
field_path: str,
|
|
98
|
+
node: Any,
|
|
99
|
+
source: str,
|
|
100
|
+
default_page: int | None,
|
|
101
|
+
default_dimensions: tuple[float, float] | None,
|
|
102
|
+
) -> list[FieldCitation]:
|
|
103
|
+
node_map = _as_mapping(node)
|
|
104
|
+
if not node_map:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
page = _extract_page(node_map) or default_page
|
|
108
|
+
dimensions = _extract_dimensions(node_map) or default_dimensions
|
|
109
|
+
citations: list[FieldCitation] = []
|
|
110
|
+
for citation in _iter_citation_entries(node_map):
|
|
111
|
+
citations.extend(
|
|
112
|
+
_normalize_citation(
|
|
113
|
+
field_path=field_path,
|
|
114
|
+
citation=citation,
|
|
115
|
+
source=source,
|
|
116
|
+
default_page=page,
|
|
117
|
+
default_dimensions=dimensions,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return citations
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _iter_citation_entries(node: Mapping[str, Any]) -> list[Any]:
|
|
124
|
+
"""Iterate citation entries supporting both plural `citations` and singular `citation` keys."""
|
|
125
|
+
entries: list[Any] = []
|
|
126
|
+
for key in ("citations", "citation"):
|
|
127
|
+
for entry in _as_sequence(node.get(key)):
|
|
128
|
+
entries.append(entry)
|
|
129
|
+
return entries
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _collect_recursive(*, node: Any, source: str, path: list[str]) -> list[FieldCitation]:
|
|
133
|
+
node_map = _as_mapping(node)
|
|
134
|
+
if not node_map:
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
citations: list[FieldCitation] = []
|
|
138
|
+
explicit_path = _extract_field_path(node_map)
|
|
139
|
+
field_path = explicit_path or _format_field_path(path)
|
|
140
|
+
if field_path:
|
|
141
|
+
for citation in _iter_citation_entries(node_map):
|
|
142
|
+
citations.extend(
|
|
143
|
+
_normalize_citation(
|
|
144
|
+
field_path=field_path,
|
|
145
|
+
citation=citation,
|
|
146
|
+
source=source,
|
|
147
|
+
default_page=_extract_page(node_map),
|
|
148
|
+
default_dimensions=_extract_dimensions(node_map),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
for key, value in node_map.items():
|
|
153
|
+
if key in ("citations", "citation"):
|
|
154
|
+
continue
|
|
155
|
+
next_path = path if key in _STRUCTURAL_KEYS else [*path, key]
|
|
156
|
+
if isinstance(value, Mapping):
|
|
157
|
+
citations.extend(_collect_recursive(node=value, source=source, path=next_path))
|
|
158
|
+
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
159
|
+
for index, item in enumerate(value):
|
|
160
|
+
item_path = next_path if key in _STRUCTURAL_KEYS else [*next_path, f"[{index}]"]
|
|
161
|
+
citations.extend(_collect_recursive(node=item, source=source, path=item_path))
|
|
162
|
+
|
|
163
|
+
return citations
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _format_field_path(path: list[str]) -> str:
|
|
167
|
+
"""Render path tokens so list-index tokens (`[N]`) attach to the prior key without a dot.
|
|
168
|
+
|
|
169
|
+
GT field paths use bracket notation (`employees[0].basic_salary`). We collect tokens during
|
|
170
|
+
the recursive walk and convert any leading-bracket tokens into bracket-joined segments so
|
|
171
|
+
predictions match GT field path scope.
|
|
172
|
+
"""
|
|
173
|
+
rendered = ""
|
|
174
|
+
for token in path:
|
|
175
|
+
if token.startswith("[") and token.endswith("]"):
|
|
176
|
+
rendered += token
|
|
177
|
+
elif rendered:
|
|
178
|
+
rendered += "." + token
|
|
179
|
+
else:
|
|
180
|
+
rendered = token
|
|
181
|
+
return rendered
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _normalize_citation(
|
|
185
|
+
*,
|
|
186
|
+
field_path: str,
|
|
187
|
+
citation: Any,
|
|
188
|
+
source: str,
|
|
189
|
+
default_page: int | None,
|
|
190
|
+
default_dimensions: tuple[float, float] | None,
|
|
191
|
+
) -> list[FieldCitation]:
|
|
192
|
+
citation_map = _as_mapping(citation)
|
|
193
|
+
if not citation_map:
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
page = _extract_page(citation_map) or default_page or 1
|
|
197
|
+
dimensions = _extract_dimensions(citation_map) or default_dimensions
|
|
198
|
+
polygon = _extract_polygon(citation_map)
|
|
199
|
+
reference_text = _extract_reference_text(citation_map)
|
|
200
|
+
confidence = _extract_confidence(citation_map)
|
|
201
|
+
metadata = _compact_metadata(citation_map)
|
|
202
|
+
|
|
203
|
+
plural_bboxes = _extract_bbox_list(citation_map)
|
|
204
|
+
if plural_bboxes:
|
|
205
|
+
normalized_polygon = _normalize_polygon(polygon, dimensions) if polygon is not None else None
|
|
206
|
+
results: list[FieldCitation] = []
|
|
207
|
+
for entry_bbox in plural_bboxes:
|
|
208
|
+
normalized_bbox = _normalize_bbox(entry_bbox, dimensions)
|
|
209
|
+
if normalized_bbox is None:
|
|
210
|
+
continue
|
|
211
|
+
results.append(
|
|
212
|
+
_field_citation_cls()(
|
|
213
|
+
field_path=field_path,
|
|
214
|
+
page=page,
|
|
215
|
+
bbox=normalized_bbox,
|
|
216
|
+
polygon=normalized_polygon,
|
|
217
|
+
reference_text=reference_text,
|
|
218
|
+
confidence=confidence,
|
|
219
|
+
source=source,
|
|
220
|
+
metadata=metadata,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
return results
|
|
224
|
+
|
|
225
|
+
raw_bbox = _bbox_from_polygon(polygon) if polygon is not None else _extract_bbox(citation_map)
|
|
226
|
+
normalized_bbox = _normalize_bbox(raw_bbox, dimensions)
|
|
227
|
+
if normalized_bbox is None:
|
|
228
|
+
return []
|
|
229
|
+
|
|
230
|
+
normalized_polygon = _normalize_polygon(polygon, dimensions) if polygon is not None else None
|
|
231
|
+
return [
|
|
232
|
+
_field_citation_cls()(
|
|
233
|
+
field_path=field_path,
|
|
234
|
+
page=page,
|
|
235
|
+
bbox=normalized_bbox,
|
|
236
|
+
polygon=normalized_polygon,
|
|
237
|
+
reference_text=reference_text,
|
|
238
|
+
confidence=confidence,
|
|
239
|
+
source=source,
|
|
240
|
+
metadata=metadata,
|
|
241
|
+
)
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _extract_bbox_list(node: Mapping[str, Any]) -> list[list[float]] | None:
|
|
246
|
+
"""Extract a plural list of bboxes if `bounding_boxes` is present.
|
|
247
|
+
|
|
248
|
+
Each entry can be either a 4-element [x, y, w, h] sequence or a mapping with
|
|
249
|
+
x/y/w/h or x1/y1/x2/y2 keys.
|
|
250
|
+
"""
|
|
251
|
+
raw = node.get("bounding_boxes")
|
|
252
|
+
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes, bytearray)):
|
|
253
|
+
return None
|
|
254
|
+
if not raw:
|
|
255
|
+
return None
|
|
256
|
+
bboxes: list[list[float]] = []
|
|
257
|
+
for entry in raw:
|
|
258
|
+
bbox: list[float] | None = None
|
|
259
|
+
if isinstance(entry, Mapping):
|
|
260
|
+
bbox = _bbox_from_mapping(entry)
|
|
261
|
+
elif isinstance(entry, Sequence) and not isinstance(entry, (str, bytes, bytearray)):
|
|
262
|
+
bbox = _bbox_from_sequence(entry)
|
|
263
|
+
if bbox is not None:
|
|
264
|
+
bboxes.append(bbox)
|
|
265
|
+
return bboxes or None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _extract_field_path(node: Mapping[str, Any]) -> str | None:
|
|
269
|
+
for key in ("field_path", "fieldPath", "path", "field", "name", "key"):
|
|
270
|
+
value = node.get(key)
|
|
271
|
+
if isinstance(value, str) and value:
|
|
272
|
+
return value
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _extract_page(node: Mapping[str, Any]) -> int | None:
|
|
277
|
+
for key in ("page", "page_number", "pageNumber"):
|
|
278
|
+
value = _coerce_int(node.get(key))
|
|
279
|
+
if value is not None and value >= 1:
|
|
280
|
+
return value
|
|
281
|
+
for key in ("page_index", "pageIndex"):
|
|
282
|
+
value = _coerce_int(node.get(key))
|
|
283
|
+
if value is not None and value >= 0:
|
|
284
|
+
return value + 1
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _extract_dimensions(node: Mapping[str, Any]) -> tuple[float, float] | None:
|
|
289
|
+
width = _coerce_float(_first_present(node, ("page_width", "pageWidth", "width", "image_width", "imageWidth")))
|
|
290
|
+
height = _coerce_float(_first_present(node, ("page_height", "pageHeight", "height", "image_height", "imageHeight")))
|
|
291
|
+
if width is not None and height is not None and width > 0 and height > 0:
|
|
292
|
+
return width, height
|
|
293
|
+
|
|
294
|
+
for key in ("page_dimensions", "pageDimensions", "page_size", "pageSize", "dimensions", "image_size", "imageSize"):
|
|
295
|
+
size = _as_mapping(node.get(key))
|
|
296
|
+
width = _coerce_float(_first_present(size, ("width", "w")))
|
|
297
|
+
height = _coerce_float(_first_present(size, ("height", "h")))
|
|
298
|
+
if width is not None and height is not None and width > 0 and height > 0:
|
|
299
|
+
return width, height
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _extract_bbox(node: Mapping[str, Any]) -> list[float] | None:
|
|
304
|
+
for key in ("bbox", "bounding_box", "boundingBox", "box"):
|
|
305
|
+
bbox = node.get(key)
|
|
306
|
+
bbox_from_dict = _bbox_from_mapping(_as_mapping(bbox))
|
|
307
|
+
if bbox_from_dict is not None:
|
|
308
|
+
return bbox_from_dict
|
|
309
|
+
bbox_from_sequence = _bbox_from_sequence(bbox)
|
|
310
|
+
if bbox_from_sequence is not None:
|
|
311
|
+
return bbox_from_sequence
|
|
312
|
+
|
|
313
|
+
bbox_from_dict = _bbox_from_mapping(node)
|
|
314
|
+
if bbox_from_dict is not None:
|
|
315
|
+
return bbox_from_dict
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _bbox_from_mapping(node: Mapping[str, Any]) -> list[float] | None:
|
|
320
|
+
if not node:
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
x = _coerce_float(_first_present(node, ("x", "left")))
|
|
324
|
+
y = _coerce_float(_first_present(node, ("y", "top")))
|
|
325
|
+
width = _coerce_float(_first_present(node, ("w", "width")))
|
|
326
|
+
height = _coerce_float(_first_present(node, ("h", "height")))
|
|
327
|
+
if x is not None and y is not None and width is not None and height is not None:
|
|
328
|
+
return [x, y, width, height]
|
|
329
|
+
|
|
330
|
+
x1 = _coerce_float(_first_present(node, ("x1", "left")))
|
|
331
|
+
y1 = _coerce_float(_first_present(node, ("y1", "top")))
|
|
332
|
+
x2 = _coerce_float(_first_present(node, ("x2", "right")))
|
|
333
|
+
y2 = _coerce_float(_first_present(node, ("y2", "bottom")))
|
|
334
|
+
if x1 is not None and y1 is not None and x2 is not None and y2 is not None:
|
|
335
|
+
return [x1, y1, x2 - x1, y2 - y1]
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _bbox_from_sequence(raw: Any) -> list[float] | None:
|
|
340
|
+
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes, bytearray)) or len(raw) != 4:
|
|
341
|
+
return None
|
|
342
|
+
values = [_coerce_float(value) for value in raw]
|
|
343
|
+
if any(value is None for value in values):
|
|
344
|
+
return None
|
|
345
|
+
return [float(value) for value in values if value is not None]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _extract_polygon(node: Mapping[str, Any]) -> list[list[float]] | None:
|
|
349
|
+
for key in ("polygon", "bounding_polygon", "boundingPolygon", "points", "vertices"):
|
|
350
|
+
polygon = _polygon_from_raw(node.get(key))
|
|
351
|
+
if polygon is not None:
|
|
352
|
+
return polygon
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _polygon_from_raw(raw: Any) -> list[list[float]] | None:
|
|
357
|
+
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes, bytearray)):
|
|
358
|
+
return None
|
|
359
|
+
if not raw:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
points: list[list[float]] = []
|
|
363
|
+
if all(isinstance(point, Mapping) for point in raw):
|
|
364
|
+
for point in raw:
|
|
365
|
+
point_map = _as_mapping(point)
|
|
366
|
+
x = _coerce_float(point_map.get("x"))
|
|
367
|
+
y = _coerce_float(point_map.get("y"))
|
|
368
|
+
if x is None or y is None:
|
|
369
|
+
return None
|
|
370
|
+
points.append([x, y])
|
|
371
|
+
elif all(isinstance(point, Sequence) and not isinstance(point, (str, bytes, bytearray)) for point in raw):
|
|
372
|
+
for point in raw:
|
|
373
|
+
if len(point) < 2:
|
|
374
|
+
return None
|
|
375
|
+
x = _coerce_float(point[0])
|
|
376
|
+
y = _coerce_float(point[1])
|
|
377
|
+
if x is None or y is None:
|
|
378
|
+
return None
|
|
379
|
+
points.append([x, y])
|
|
380
|
+
else:
|
|
381
|
+
values = [_coerce_float(value) for value in raw]
|
|
382
|
+
if len(values) % 2 != 0 or any(value is None for value in values):
|
|
383
|
+
return None
|
|
384
|
+
numeric_values = [float(value) for value in values if value is not None]
|
|
385
|
+
points = [[numeric_values[index], numeric_values[index + 1]] for index in range(0, len(numeric_values), 2)]
|
|
386
|
+
|
|
387
|
+
return points if len(points) >= 2 else None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _bbox_from_polygon(polygon: list[list[float]] | None) -> list[float] | None:
|
|
391
|
+
if not polygon:
|
|
392
|
+
return None
|
|
393
|
+
xs = [point[0] for point in polygon]
|
|
394
|
+
ys = [point[1] for point in polygon]
|
|
395
|
+
left = min(xs)
|
|
396
|
+
top = min(ys)
|
|
397
|
+
return [left, top, max(xs) - left, max(ys) - top]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _normalize_bbox(raw_bbox: list[float] | None, dimensions: tuple[float, float] | None) -> list[float] | None:
|
|
401
|
+
if raw_bbox is None or len(raw_bbox) != 4:
|
|
402
|
+
return None
|
|
403
|
+
x, y, width, height = raw_bbox
|
|
404
|
+
if width <= 0 or height <= 0:
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
if _looks_normalized(raw_bbox):
|
|
408
|
+
normalized = raw_bbox
|
|
409
|
+
elif dimensions is not None:
|
|
410
|
+
page_width, page_height = dimensions
|
|
411
|
+
normalized = [x / page_width, y / page_height, width / page_width, height / page_height]
|
|
412
|
+
else:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
if not _looks_normalized(normalized):
|
|
416
|
+
return None
|
|
417
|
+
return [round(value, 8) for value in normalized]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _normalize_polygon(
|
|
421
|
+
polygon: list[list[float]] | None,
|
|
422
|
+
dimensions: tuple[float, float] | None,
|
|
423
|
+
) -> list[list[float]] | None:
|
|
424
|
+
if polygon is None:
|
|
425
|
+
return None
|
|
426
|
+
flat = [coordinate for point in polygon for coordinate in point]
|
|
427
|
+
if all(0 <= value <= 1 for value in flat):
|
|
428
|
+
return [[round(point[0], 8), round(point[1], 8)] for point in polygon]
|
|
429
|
+
if dimensions is None:
|
|
430
|
+
return None
|
|
431
|
+
page_width, page_height = dimensions
|
|
432
|
+
normalized = [[point[0] / page_width, point[1] / page_height] for point in polygon]
|
|
433
|
+
if not all(0 <= value <= 1 for point in normalized for value in point):
|
|
434
|
+
return None
|
|
435
|
+
return [[round(point[0], 8), round(point[1], 8)] for point in normalized]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _looks_normalized(bbox: list[float]) -> bool:
|
|
439
|
+
x, y, width, height = bbox
|
|
440
|
+
return (
|
|
441
|
+
0 <= x <= 1
|
|
442
|
+
and 0 <= y <= 1
|
|
443
|
+
and 0 < width <= 1
|
|
444
|
+
and 0 < height <= 1
|
|
445
|
+
and x + width <= 1.000001
|
|
446
|
+
and y + height <= 1.000001
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _extract_reference_text(node: Mapping[str, Any]) -> str | None:
|
|
451
|
+
value = _first_present(
|
|
452
|
+
node, ("reference_text", "referenceText", "matching_text", "matchingText", "text", "content", "value")
|
|
453
|
+
)
|
|
454
|
+
if isinstance(value, str):
|
|
455
|
+
return value
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _extract_confidence(node: Mapping[str, Any]) -> float | None:
|
|
460
|
+
confidence = _coerce_float(_first_present(node, ("confidence", "score", "probability")))
|
|
461
|
+
if confidence is None:
|
|
462
|
+
return None
|
|
463
|
+
return confidence
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _compact_metadata(node: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
467
|
+
metadata = {
|
|
468
|
+
key: value
|
|
469
|
+
for key, value in node.items()
|
|
470
|
+
if key
|
|
471
|
+
not in {
|
|
472
|
+
"bbox",
|
|
473
|
+
"bounding_box",
|
|
474
|
+
"boundingBox",
|
|
475
|
+
"box",
|
|
476
|
+
"bounding_boxes",
|
|
477
|
+
"polygon",
|
|
478
|
+
"bounding_polygon",
|
|
479
|
+
"boundingPolygon",
|
|
480
|
+
"points",
|
|
481
|
+
"vertices",
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return dict(metadata) if metadata else None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _dedupe(citations: list[FieldCitation]) -> list[FieldCitation]:
|
|
488
|
+
seen: set[tuple[Any, ...]] = set()
|
|
489
|
+
deduped: list[FieldCitation] = []
|
|
490
|
+
for citation in citations:
|
|
491
|
+
key = (
|
|
492
|
+
citation.field_path,
|
|
493
|
+
citation.page,
|
|
494
|
+
tuple(citation.bbox),
|
|
495
|
+
citation.reference_text,
|
|
496
|
+
citation.source,
|
|
497
|
+
)
|
|
498
|
+
if key in seen:
|
|
499
|
+
continue
|
|
500
|
+
seen.add(key)
|
|
501
|
+
deduped.append(citation)
|
|
502
|
+
return deduped
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _as_mapping(value: Any) -> Mapping[str, Any]:
|
|
506
|
+
return value if isinstance(value, Mapping) else {}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _as_sequence(value: Any) -> Sequence[Any]:
|
|
510
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
511
|
+
return value
|
|
512
|
+
return []
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _first_present(node: Mapping[str, Any], keys: tuple[str, ...]) -> Any:
|
|
516
|
+
for key in keys:
|
|
517
|
+
if key in node:
|
|
518
|
+
return node[key]
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _coerce_float(value: Any) -> float | None:
|
|
523
|
+
if isinstance(value, bool) or value is None:
|
|
524
|
+
return None
|
|
525
|
+
if isinstance(value, (int, float)):
|
|
526
|
+
return float(value)
|
|
527
|
+
if isinstance(value, str):
|
|
528
|
+
try:
|
|
529
|
+
return float(value)
|
|
530
|
+
except ValueError:
|
|
531
|
+
return None
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _coerce_int(value: Any) -> int | None:
|
|
536
|
+
if isinstance(value, bool) or value is None:
|
|
537
|
+
return None
|
|
538
|
+
if isinstance(value, int):
|
|
539
|
+
return value
|
|
540
|
+
if isinstance(value, float) and value.is_integer():
|
|
541
|
+
return int(value)
|
|
542
|
+
if isinstance(value, str):
|
|
543
|
+
try:
|
|
544
|
+
parsed = float(value)
|
|
545
|
+
except ValueError:
|
|
546
|
+
return None
|
|
547
|
+
if parsed.is_integer():
|
|
548
|
+
return int(parsed)
|
|
549
|
+
return None
|