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,340 @@
|
|
|
1
|
+
"""Normalize list-rooted per_table_row extract predictions for evaluation.
|
|
2
|
+
|
|
3
|
+
This module is a pure **shape adapter**. It does not emit any metrics of
|
|
4
|
+
its own. The existing ``extract_value_precision``,
|
|
5
|
+
``extract_value_recall``, ``extract_value_f1``, ``accuracy``, and
|
|
6
|
+
``extract_value_pass_rate`` metrics are what score correctly once the
|
|
7
|
+
prediction is normalized; downstream metadata on those metrics carries
|
|
8
|
+
normalization state for debugging and dashboard drill-down.
|
|
9
|
+
|
|
10
|
+
The v0.5 test cases were authored for ``extraction_target=per_doc``, so every
|
|
11
|
+
``ExtractFieldTestRule.field_path`` is dict-rooted (e.g. ``personnel[0].name``,
|
|
12
|
+
``client_id``). ``extraction_target=per_table_row`` may emit a bare row list::
|
|
13
|
+
|
|
14
|
+
extracted_data = [{"name": "Alice", ...}, {"name": "Bob", ...}]
|
|
15
|
+
|
|
16
|
+
or, when the API is given the original per-doc schema, a list of document-shaped
|
|
17
|
+
wrappers where each wrapper contains the inferred array field::
|
|
18
|
+
|
|
19
|
+
extracted_data = [
|
|
20
|
+
{"client_id": "C-1", "personnel": [{"name": "Alice"}]},
|
|
21
|
+
{"client_id": "C-1", "personnel": [{"name": "Bob"}]},
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
The full DataSnipper v0.5 run also exposed singleton scalar-document lists and
|
|
25
|
+
multi-array wrapper lists. The extract evaluator's path walkers are all
|
|
26
|
+
dict-rooted, so this module projects those list-rooted shapes back into the
|
|
27
|
+
per-doc schema shape before scoring.
|
|
28
|
+
|
|
29
|
+
Scope
|
|
30
|
+
-----
|
|
31
|
+
- Dict-rooted predictions are a no-op.
|
|
32
|
+
- Singleton scalar-document lists become the singleton dict.
|
|
33
|
+
- Wrapper rows merge all top-level list fields and preserve representative
|
|
34
|
+
scalar fields.
|
|
35
|
+
- Bare row lists still require one canonical array prefix; scalar rules are
|
|
36
|
+
skipped only in this mode because bare rows cannot structurally emit them.
|
|
37
|
+
- Case-only rule aliases are skipped when the JSON schema has a unique
|
|
38
|
+
canonical top-level key.
|
|
39
|
+
|
|
40
|
+
Accuracy caveat
|
|
41
|
+
---------------
|
|
42
|
+
The whole-JSON ``JsonSubsetMatchMetric`` intentionally still operates on the
|
|
43
|
+
unmodified ``expected_output`` vs the unwrapped ``extracted_data``. Accuracy
|
|
44
|
+
will honestly drop on per_table_row runs because scalar fields like
|
|
45
|
+
``client_id`` may be missing from bare-row predictions. That drop is a
|
|
46
|
+
correct signal for whole-JSON accuracy, and is separate from the rule-level
|
|
47
|
+
P/R/F1 this adapter fixes.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
from collections.abc import Iterable
|
|
53
|
+
from dataclasses import dataclass, field
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
from parse_bench.test_cases.extract_field_paths import parse_field_path
|
|
57
|
+
from parse_bench.test_cases.schema import ExtractFieldTestRule
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ListPredictionNormalization:
|
|
62
|
+
"""Result of projecting a list-rooted prediction into evaluator shape."""
|
|
63
|
+
|
|
64
|
+
extracted_data: Any
|
|
65
|
+
applied: bool
|
|
66
|
+
mode: str
|
|
67
|
+
skipped_field_paths: list[str] = field(default_factory=list)
|
|
68
|
+
alias_skipped_field_paths: list[str] = field(default_factory=list)
|
|
69
|
+
normalized_top_level_keys: list[str] = field(default_factory=list)
|
|
70
|
+
warnings: list[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def infer_array_field(rules: Iterable[ExtractFieldTestRule]) -> str | None:
|
|
74
|
+
"""Return the single top-level array-prefix used by all array-rooted rules.
|
|
75
|
+
|
|
76
|
+
For paths like ``personnel[0].name`` and ``personnel[1].net_pay``, returns
|
|
77
|
+
``"personnel"``. Returns ``None`` if rules span multiple array prefixes or
|
|
78
|
+
if no rule is array-rooted.
|
|
79
|
+
"""
|
|
80
|
+
array_prefixes: set[str] = set()
|
|
81
|
+
for rule in rules:
|
|
82
|
+
try:
|
|
83
|
+
tokens = parse_field_path(rule.field_path)
|
|
84
|
+
except ValueError:
|
|
85
|
+
continue
|
|
86
|
+
# Array-rooted rule: first token is a string, second is an int.
|
|
87
|
+
if len(tokens) >= 2 and isinstance(tokens[0], str) and isinstance(tokens[1], int):
|
|
88
|
+
array_prefixes.add(tokens[0])
|
|
89
|
+
if len(array_prefixes) == 1:
|
|
90
|
+
return next(iter(array_prefixes))
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_tokens(field_path: str) -> list[str | int] | None:
|
|
95
|
+
try:
|
|
96
|
+
return list(parse_field_path(field_path))
|
|
97
|
+
except ValueError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _top_level_field(field_path: str) -> str | None:
|
|
102
|
+
tokens = _parse_tokens(field_path)
|
|
103
|
+
if tokens and isinstance(tokens[0], str):
|
|
104
|
+
return tokens[0]
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_scalar_rooted_path(field_path: str) -> bool:
|
|
109
|
+
"""Return True if ``field_path`` doesn't traverse any array.
|
|
110
|
+
|
|
111
|
+
Top-level scalars (``client_id``) and nested-dict-only paths
|
|
112
|
+
(``buyer.company``) are considered scalar-rooted. Any path containing a
|
|
113
|
+
numeric index token is array-rooted.
|
|
114
|
+
"""
|
|
115
|
+
tokens = _parse_tokens(field_path)
|
|
116
|
+
if tokens is None:
|
|
117
|
+
return False
|
|
118
|
+
return not any(isinstance(token, int) for token in tokens)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_array_rooted_path(field_path: str) -> bool:
|
|
122
|
+
tokens = _parse_tokens(field_path)
|
|
123
|
+
return bool(tokens and len(tokens) >= 2 and isinstance(tokens[0], str) and isinstance(tokens[1], int))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _schema_canonical_key_map(data_schema: dict[str, Any] | None) -> dict[str, str]:
|
|
127
|
+
if not isinstance(data_schema, dict):
|
|
128
|
+
return {}
|
|
129
|
+
properties = data_schema.get("properties")
|
|
130
|
+
if not isinstance(properties, dict):
|
|
131
|
+
return {}
|
|
132
|
+
|
|
133
|
+
by_casefold: dict[str, list[str]] = {}
|
|
134
|
+
for key in properties:
|
|
135
|
+
if isinstance(key, str):
|
|
136
|
+
by_casefold.setdefault(key.casefold(), []).append(key)
|
|
137
|
+
return {folded: keys[0] for folded, keys in by_casefold.items() if len(keys) == 1}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _canonicalize_key(key: str, canonical_keys: dict[str, str]) -> str:
|
|
141
|
+
return canonical_keys.get(key.casefold(), key)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _array_prefixes(
|
|
145
|
+
rules: Iterable[ExtractFieldTestRule],
|
|
146
|
+
canonical_keys: dict[str, str],
|
|
147
|
+
) -> set[str]:
|
|
148
|
+
prefixes: set[str] = set()
|
|
149
|
+
for rule in rules:
|
|
150
|
+
if not _is_array_rooted_path(rule.field_path):
|
|
151
|
+
continue
|
|
152
|
+
top_level = _top_level_field(rule.field_path)
|
|
153
|
+
if top_level is not None:
|
|
154
|
+
prefixes.add(_canonicalize_key(top_level, canonical_keys))
|
|
155
|
+
return prefixes
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _alias_skipped_field_paths(
|
|
159
|
+
rules: Iterable[ExtractFieldTestRule],
|
|
160
|
+
canonical_keys: dict[str, str],
|
|
161
|
+
) -> list[str]:
|
|
162
|
+
skipped: list[str] = []
|
|
163
|
+
for rule in rules:
|
|
164
|
+
top_level = _top_level_field(rule.field_path)
|
|
165
|
+
if top_level is None:
|
|
166
|
+
continue
|
|
167
|
+
canonical = _canonicalize_key(top_level, canonical_keys)
|
|
168
|
+
if canonical != top_level:
|
|
169
|
+
skipped.append(rule.field_path)
|
|
170
|
+
return skipped
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _all_items_are_dicts(extracted_data: list[Any]) -> bool:
|
|
174
|
+
return all(isinstance(item, dict) for item in extracted_data)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _has_list_valued_field(extracted_data: list[Any]) -> bool:
|
|
178
|
+
return any(isinstance(value, list) for item in extracted_data if isinstance(item, dict) for value in item.values())
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _merge_wrapper_rows(
|
|
182
|
+
extracted_data: list[Any],
|
|
183
|
+
canonical_keys: dict[str, str],
|
|
184
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
185
|
+
merged: dict[str, Any] = {}
|
|
186
|
+
scalar_values: dict[str, Any] = {}
|
|
187
|
+
scalar_conflicts: dict[str, set[str]] = {}
|
|
188
|
+
|
|
189
|
+
for item in extracted_data:
|
|
190
|
+
if not isinstance(item, dict):
|
|
191
|
+
continue
|
|
192
|
+
for raw_key, value in item.items():
|
|
193
|
+
if not isinstance(raw_key, str):
|
|
194
|
+
continue
|
|
195
|
+
key = _canonicalize_key(raw_key, canonical_keys)
|
|
196
|
+
if isinstance(value, list):
|
|
197
|
+
existing = merged.setdefault(key, [])
|
|
198
|
+
if isinstance(existing, list):
|
|
199
|
+
existing.extend(row for row in value if row is not None)
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
if _is_empty_scalar(value):
|
|
203
|
+
continue
|
|
204
|
+
if key not in scalar_values:
|
|
205
|
+
scalar_values[key] = value
|
|
206
|
+
elif scalar_values[key] != value:
|
|
207
|
+
scalar_conflicts.setdefault(key, {repr(scalar_values[key])}).add(repr(value))
|
|
208
|
+
|
|
209
|
+
for key, value in scalar_values.items():
|
|
210
|
+
merged.setdefault(key, value)
|
|
211
|
+
|
|
212
|
+
warnings = [
|
|
213
|
+
f"conflicting scalar values for {key}: {sorted(values)}" for key, values in sorted(scalar_conflicts.items())
|
|
214
|
+
]
|
|
215
|
+
return merged, warnings
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_empty_scalar(value: Any) -> bool:
|
|
219
|
+
return value is None or value == ""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def normalize_list_prediction(
|
|
223
|
+
extracted_data: Any,
|
|
224
|
+
rules: Iterable[ExtractFieldTestRule],
|
|
225
|
+
*,
|
|
226
|
+
data_schema: dict[str, Any] | None = None,
|
|
227
|
+
) -> ListPredictionNormalization:
|
|
228
|
+
"""Project list-rooted predictions into the dict-rooted evaluator shape."""
|
|
229
|
+
if not isinstance(extracted_data, list):
|
|
230
|
+
return ListPredictionNormalization(
|
|
231
|
+
extracted_data=extracted_data,
|
|
232
|
+
applied=False,
|
|
233
|
+
mode="no_op",
|
|
234
|
+
normalized_top_level_keys=sorted(extracted_data.keys()) if isinstance(extracted_data, dict) else [],
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
rules_list = list(rules)
|
|
238
|
+
canonical_keys = _schema_canonical_key_map(data_schema)
|
|
239
|
+
alias_skipped = _alias_skipped_field_paths(rules_list, canonical_keys)
|
|
240
|
+
alias_skipped_set = set(alias_skipped)
|
|
241
|
+
scoreable_rules = [rule for rule in rules_list if rule.field_path not in alias_skipped_set]
|
|
242
|
+
array_prefixes = _array_prefixes(scoreable_rules, canonical_keys)
|
|
243
|
+
|
|
244
|
+
if not extracted_data:
|
|
245
|
+
if len(array_prefixes) == 1:
|
|
246
|
+
array_field = next(iter(array_prefixes))
|
|
247
|
+
return ListPredictionNormalization(
|
|
248
|
+
extracted_data={array_field: []},
|
|
249
|
+
applied=True,
|
|
250
|
+
mode="bare_rows",
|
|
251
|
+
skipped_field_paths=[
|
|
252
|
+
rule.field_path for rule in scoreable_rules if _is_scalar_rooted_path(rule.field_path)
|
|
253
|
+
],
|
|
254
|
+
alias_skipped_field_paths=alias_skipped,
|
|
255
|
+
normalized_top_level_keys=[array_field],
|
|
256
|
+
)
|
|
257
|
+
return ListPredictionNormalization(
|
|
258
|
+
extracted_data=extracted_data,
|
|
259
|
+
applied=False,
|
|
260
|
+
mode="no_op",
|
|
261
|
+
alias_skipped_field_paths=alias_skipped,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
(rules_list or canonical_keys)
|
|
266
|
+
and not array_prefixes
|
|
267
|
+
and len(extracted_data) == 1
|
|
268
|
+
and isinstance(extracted_data[0], dict)
|
|
269
|
+
and not _has_list_valued_field(extracted_data)
|
|
270
|
+
):
|
|
271
|
+
normalized = {
|
|
272
|
+
_canonicalize_key(key, canonical_keys): value
|
|
273
|
+
for key, value in extracted_data[0].items()
|
|
274
|
+
if isinstance(key, str)
|
|
275
|
+
}
|
|
276
|
+
return ListPredictionNormalization(
|
|
277
|
+
extracted_data=normalized,
|
|
278
|
+
applied=True,
|
|
279
|
+
mode="singleton_doc",
|
|
280
|
+
alias_skipped_field_paths=alias_skipped,
|
|
281
|
+
normalized_top_level_keys=sorted(normalized.keys()),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if _all_items_are_dicts(extracted_data) and _has_list_valued_field(extracted_data):
|
|
285
|
+
merged, warnings = _merge_wrapper_rows(extracted_data, canonical_keys)
|
|
286
|
+
return ListPredictionNormalization(
|
|
287
|
+
extracted_data=merged,
|
|
288
|
+
applied=True,
|
|
289
|
+
mode="wrapper_merge",
|
|
290
|
+
alias_skipped_field_paths=alias_skipped,
|
|
291
|
+
normalized_top_level_keys=sorted(merged.keys()),
|
|
292
|
+
warnings=warnings,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if len(array_prefixes) == 1:
|
|
296
|
+
array_field = next(iter(array_prefixes))
|
|
297
|
+
skipped = [rule.field_path for rule in scoreable_rules if _is_scalar_rooted_path(rule.field_path)]
|
|
298
|
+
return ListPredictionNormalization(
|
|
299
|
+
extracted_data={array_field: extracted_data},
|
|
300
|
+
applied=True,
|
|
301
|
+
mode="bare_rows",
|
|
302
|
+
skipped_field_paths=skipped,
|
|
303
|
+
alias_skipped_field_paths=alias_skipped,
|
|
304
|
+
normalized_top_level_keys=[array_field],
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return ListPredictionNormalization(
|
|
308
|
+
extracted_data=extracted_data,
|
|
309
|
+
applied=False,
|
|
310
|
+
mode="no_op",
|
|
311
|
+
alias_skipped_field_paths=alias_skipped,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def unwrap_list_prediction(
|
|
316
|
+
extracted_data: Any,
|
|
317
|
+
rules: Iterable[ExtractFieldTestRule],
|
|
318
|
+
*,
|
|
319
|
+
data_schema: dict[str, Any] | None = None,
|
|
320
|
+
) -> tuple[Any, bool, list[str]]:
|
|
321
|
+
"""Return ``(wrapped_data, unwrap_applied, skipped_field_paths)``.
|
|
322
|
+
|
|
323
|
+
If ``extracted_data`` is a list and the rules share a single array-prefix,
|
|
324
|
+
return a dict rooted at that prefix. Bare row lists become
|
|
325
|
+
``{prefix: extracted_data}``; wrapper-per-row lists become
|
|
326
|
+
``{prefix: flattened_rows}``. Also return the list of field_paths that
|
|
327
|
+
don't touch any array (those can't be scored against a list-rooted
|
|
328
|
+
prediction — caller should exclude them from denominators).
|
|
329
|
+
|
|
330
|
+
If ``extracted_data`` is not a list or no single array-prefix is inferable,
|
|
331
|
+
returns ``(extracted_data, False, [])`` unchanged. In particular, this is
|
|
332
|
+
a no-op for the common per_doc case where predictions are already
|
|
333
|
+
dict-rooted.
|
|
334
|
+
"""
|
|
335
|
+
normalized = normalize_list_prediction(extracted_data, rules, data_schema=data_schema)
|
|
336
|
+
return (
|
|
337
|
+
normalized.extracted_data,
|
|
338
|
+
normalized.applied,
|
|
339
|
+
[*normalized.skipped_field_paths, *normalized.alias_skipped_field_paths],
|
|
340
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Rule-based metric for executing extract test rules."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from parse_bench.evaluation.metrics.base import Metric
|
|
6
|
+
from parse_bench.evaluation.metrics.extract.test_rules import create_test_rule
|
|
7
|
+
from parse_bench.schemas.evaluation import MetricValue
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExtractRuleBasedMetric(Metric):
|
|
11
|
+
"""Metric for executing test rules against extracted JSON data."""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def name(self) -> str:
|
|
15
|
+
"""Return the name of this metric."""
|
|
16
|
+
return "rule_pass_rate"
|
|
17
|
+
|
|
18
|
+
def compute(
|
|
19
|
+
self,
|
|
20
|
+
expected: list[dict[str, Any]] | None,
|
|
21
|
+
actual: dict[str, Any],
|
|
22
|
+
**kwargs: Any,
|
|
23
|
+
) -> MetricValue:
|
|
24
|
+
"""
|
|
25
|
+
Execute test rules against extracted JSON data.
|
|
26
|
+
|
|
27
|
+
:param expected: List of test rule definitions (from test_rules)
|
|
28
|
+
:param actual: Actual extracted JSON data to test
|
|
29
|
+
:param kwargs: Additional parameters (not used)
|
|
30
|
+
:return: MetricValue with pass rate and per-rule results
|
|
31
|
+
"""
|
|
32
|
+
if not expected:
|
|
33
|
+
return MetricValue(
|
|
34
|
+
metric_name=self.name,
|
|
35
|
+
value=1.0, # No rules means pass
|
|
36
|
+
metadata={"note": "No test rules provided"},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if not actual:
|
|
40
|
+
return MetricValue(
|
|
41
|
+
metric_name=self.name,
|
|
42
|
+
value=0.0,
|
|
43
|
+
metadata={"note": "No extracted data provided"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Execute each rule
|
|
47
|
+
passed = 0
|
|
48
|
+
total = len(expected)
|
|
49
|
+
rule_results = []
|
|
50
|
+
|
|
51
|
+
for rule_data in expected:
|
|
52
|
+
try:
|
|
53
|
+
rule = create_test_rule(rule_data)
|
|
54
|
+
rule_passed, explanation = rule.run(actual)
|
|
55
|
+
rule_results.append(
|
|
56
|
+
{
|
|
57
|
+
"type": rule_data.get("type"),
|
|
58
|
+
"id": rule_data.get("id"),
|
|
59
|
+
"name": rule_data.get("name"),
|
|
60
|
+
"path": rule_data.get("path"),
|
|
61
|
+
"passed": rule_passed,
|
|
62
|
+
"explanation": explanation,
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
if rule_passed:
|
|
66
|
+
passed += 1
|
|
67
|
+
except Exception as e:
|
|
68
|
+
# If rule execution fails, count as failed
|
|
69
|
+
rule_results.append(
|
|
70
|
+
{
|
|
71
|
+
"type": rule_data.get("type"),
|
|
72
|
+
"id": rule_data.get("id"),
|
|
73
|
+
"name": rule_data.get("name"),
|
|
74
|
+
"path": rule_data.get("path"),
|
|
75
|
+
"passed": False,
|
|
76
|
+
"explanation": f"Error executing rule: {e}",
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
pass_rate = passed / total if total > 0 else 0.0
|
|
81
|
+
|
|
82
|
+
return MetricValue(
|
|
83
|
+
metric_name=self.name,
|
|
84
|
+
value=pass_rate,
|
|
85
|
+
metadata={
|
|
86
|
+
"passed": passed,
|
|
87
|
+
"total": total,
|
|
88
|
+
"rule_results": rule_results,
|
|
89
|
+
},
|
|
90
|
+
)
|