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,1071 @@
|
|
|
1
|
+
"""Pydantic schemas for parse rule payloads.
|
|
2
|
+
|
|
3
|
+
The parse rule pipeline uses loosely typed dictionaries today. These models add
|
|
4
|
+
structured validation while keeping compatibility with legacy payloads via
|
|
5
|
+
`extra="allow"`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
13
|
+
|
|
14
|
+
from parse_bench.evaluation.metrics.parse.test_types import TestType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DictCompatPydanticModel(BaseModel):
|
|
18
|
+
"""Base model with a light dict-like `get` interface."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
21
|
+
|
|
22
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
23
|
+
# O(1) lookup: check field names, extra fields, then aliases
|
|
24
|
+
cls_fields = type(self).model_fields
|
|
25
|
+
if key in cls_fields or (self.model_extra and key in self.model_extra):
|
|
26
|
+
return getattr(self, key)
|
|
27
|
+
# Check aliases (e.g. "_csv_path" → csv_path)
|
|
28
|
+
for field_name, field_info in cls_fields.items():
|
|
29
|
+
if field_info.alias == key:
|
|
30
|
+
return getattr(self, field_name)
|
|
31
|
+
return default
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ParseRuleBase(DictCompatPydanticModel):
|
|
35
|
+
"""Base schema shared by all typed parse rules.
|
|
36
|
+
|
|
37
|
+
`id` is intentionally optional so existing datasets without rule ids keep
|
|
38
|
+
loading cleanly while new ids can be added incrementally.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
type: str
|
|
42
|
+
id: str | None = Field(
|
|
43
|
+
default=None,
|
|
44
|
+
description="Optional stable identifier for the rule",
|
|
45
|
+
)
|
|
46
|
+
page: int | None = Field(default=None)
|
|
47
|
+
max_diffs: int | float = Field(default=0, description="Allowed Levenshtein distance")
|
|
48
|
+
tags: list[str] = Field(default_factory=list)
|
|
49
|
+
layout_id: str | None = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
description="Primary linked layout element id for visual grounding.",
|
|
52
|
+
)
|
|
53
|
+
layout_ids: list[str] = Field(
|
|
54
|
+
default_factory=list,
|
|
55
|
+
description="All linked layout element ids for visual grounding.",
|
|
56
|
+
)
|
|
57
|
+
layout_bindings: dict[str, str | list[str]] = Field(
|
|
58
|
+
default_factory=dict,
|
|
59
|
+
description="Optional role-based grounding map (e.g. before/after for order rules).",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
63
|
+
|
|
64
|
+
@model_validator(mode="after")
|
|
65
|
+
def normalize_layout_grounding(self) -> ParseRuleBase:
|
|
66
|
+
"""Normalize grounding fields into a consistent, deduped representation."""
|
|
67
|
+
|
|
68
|
+
ordered_ids: list[str] = []
|
|
69
|
+
if self.layout_id:
|
|
70
|
+
ordered_ids.append(self.layout_id)
|
|
71
|
+
|
|
72
|
+
if self.layout_ids:
|
|
73
|
+
ordered_ids.extend(layout_id for layout_id in self.layout_ids if isinstance(layout_id, str))
|
|
74
|
+
|
|
75
|
+
if self.layout_bindings:
|
|
76
|
+
for value in self.layout_bindings.values():
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
ordered_ids.append(value)
|
|
79
|
+
elif isinstance(value, list):
|
|
80
|
+
ordered_ids.extend(v for v in value if isinstance(v, str))
|
|
81
|
+
|
|
82
|
+
deduped_ids: list[str] = []
|
|
83
|
+
seen: set[str] = set()
|
|
84
|
+
for layout_id in ordered_ids:
|
|
85
|
+
if layout_id and layout_id not in seen:
|
|
86
|
+
seen.add(layout_id)
|
|
87
|
+
deduped_ids.append(layout_id)
|
|
88
|
+
|
|
89
|
+
self.layout_ids = deduped_ids
|
|
90
|
+
if self.layout_id is None and self.layout_ids:
|
|
91
|
+
self.layout_id = self.layout_ids[0]
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _HasAnchorCells(BaseModel):
|
|
96
|
+
"""Shared table anchor cells field."""
|
|
97
|
+
|
|
98
|
+
table_anchor_cells: list[Any] | None = Field(default_factory=list)
|
|
99
|
+
model_config = ConfigDict(extra="allow")
|
|
100
|
+
|
|
101
|
+
@field_validator("table_anchor_cells", mode="before")
|
|
102
|
+
@classmethod
|
|
103
|
+
def _coerce_null_to_list(cls, v: Any) -> list[Any]:
|
|
104
|
+
return v if v is not None else []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ParsePresenceRule(ParseRuleBase):
|
|
108
|
+
"""Schema for `present` and `absent` rules."""
|
|
109
|
+
|
|
110
|
+
type: Literal[TestType.PRESENT.value, TestType.ABSENT.value]
|
|
111
|
+
text: str = ""
|
|
112
|
+
keep_formatting_text_normalisation: bool = False
|
|
113
|
+
case_sensitive: bool = True
|
|
114
|
+
first_n: int | None = None
|
|
115
|
+
last_n: int | None = None
|
|
116
|
+
count: int | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _SentenceBagRule(ParseRuleBase):
|
|
120
|
+
bag_of_sentence: dict[str, int] = Field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ParseUnexpectedSentenceRule(_SentenceBagRule):
|
|
124
|
+
type: Literal[TestType.UNEXPECTED_SENTENCE.value]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ParseUnexpectedSentencePercentRule(_SentenceBagRule):
|
|
128
|
+
type: Literal[TestType.UNEXPECTED_SENTENCE_PERCENT.value]
|
|
129
|
+
original_md: str | None = Field(
|
|
130
|
+
default=None,
|
|
131
|
+
description=(
|
|
132
|
+
"Original ground-truth markdown; when provided, sentences found in this text are not counted as unexpected."
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ParseTooManySentenceOccurrenceRule(_SentenceBagRule):
|
|
138
|
+
type: Literal[TestType.TOO_MANY_SENTENCE_OCCURENCE.value]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ParseTooManySentenceOccurrencePercentRule(_SentenceBagRule):
|
|
142
|
+
type: Literal[TestType.TOO_MANY_SENTENCE_OCCURENCE_PERCENT.value]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ParseMissingSentenceRule(_SentenceBagRule):
|
|
146
|
+
type: Literal[TestType.MISSING_SENTENCE.value]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ParseMissingSentencePercentRule(_SentenceBagRule):
|
|
150
|
+
type: Literal[TestType.MISSING_SENTENCE_PERCENT.value]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ParseMissingSpecificSentenceRule(ParseRuleBase):
|
|
154
|
+
"""Check that a specific sentence is present (fail if missing)."""
|
|
155
|
+
|
|
156
|
+
type: Literal[TestType.MISSING_SPECIFIC_SENTENCE.value]
|
|
157
|
+
sentence: str = Field(description="The specific sentence to look for.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _WordBagRule(ParseRuleBase):
|
|
161
|
+
bag_of_word: dict[str, int] = Field(default_factory=dict)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ParseUnexpectedWordRule(_WordBagRule):
|
|
165
|
+
type: Literal[TestType.UNEXPECTED_WORD.value]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ParseUnexpectedWordPercentRule(_WordBagRule):
|
|
169
|
+
type: Literal[TestType.UNEXPECTED_WORD_PERCENT.value]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ParseTooManyWordOccurrenceRule(_WordBagRule):
|
|
173
|
+
type: Literal[TestType.TOO_MANY_WORD_OCCURENCE.value]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ParseTooManyWordOccurrencePercentRule(_WordBagRule):
|
|
177
|
+
type: Literal[TestType.TOO_MANY_WORD_OCCURENCE_PERCENT.value]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class ParseMissingWordRule(_WordBagRule):
|
|
181
|
+
type: Literal[TestType.MISSING_WORD.value]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class ParseMissingWordPercentRule(_WordBagRule):
|
|
185
|
+
type: Literal[TestType.MISSING_WORD_PERCENT.value]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ParseMissingSpecificWordRule(ParseRuleBase):
|
|
189
|
+
"""Check that a specific word is present (fail if missing)."""
|
|
190
|
+
|
|
191
|
+
type: Literal[TestType.MISSING_SPECIFIC_WORD.value]
|
|
192
|
+
word: str = Field(description="The specific word to look for.")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ParseExtraContentRule(_SentenceBagRule):
|
|
196
|
+
type: Literal[TestType.EXTRA_CONTENT.value]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ParseBaselineRule(ParseRuleBase):
|
|
200
|
+
type: Literal[TestType.BASELINE.value]
|
|
201
|
+
max_length: int | None = None
|
|
202
|
+
max_length_skips_image_alt_tags: bool = False
|
|
203
|
+
max_repeats: int = 30
|
|
204
|
+
check_disallowed_characters: bool = True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ParseOrderRule(ParseRuleBase):
|
|
208
|
+
type: Literal[TestType.ORDER.value]
|
|
209
|
+
before: str = ""
|
|
210
|
+
after: str = ""
|
|
211
|
+
keep_formatting_text_normalisation: bool = False
|
|
212
|
+
|
|
213
|
+
@model_validator(mode="after")
|
|
214
|
+
def validate_layout_bindings(self) -> ParseOrderRule:
|
|
215
|
+
"""Validate role-based layout grounding for order rules."""
|
|
216
|
+
|
|
217
|
+
if not self.layout_bindings:
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
allowed_roles = {"before", "after"}
|
|
221
|
+
invalid_roles = sorted(role for role in self.layout_bindings if role not in allowed_roles)
|
|
222
|
+
if invalid_roles:
|
|
223
|
+
invalid = ", ".join(invalid_roles)
|
|
224
|
+
raise ValueError(f"Order rule layout_bindings only supports roles 'before' and 'after'; got: {invalid}")
|
|
225
|
+
|
|
226
|
+
for role, value in self.layout_bindings.items():
|
|
227
|
+
if isinstance(value, list):
|
|
228
|
+
raise ValueError(f"Order rule layout_bindings.{role} must be a single layout id string")
|
|
229
|
+
if not isinstance(value, str) or not value.strip():
|
|
230
|
+
raise ValueError(f"Order rule layout_bindings.{role} must be a non-empty layout id string")
|
|
231
|
+
|
|
232
|
+
before_layout = self.layout_bindings.get("before")
|
|
233
|
+
if isinstance(before_layout, str):
|
|
234
|
+
# Some legacy datasets contain stale `layout_id` values on order rules.
|
|
235
|
+
# Canonicalize to the role-specific binding instead of failing load.
|
|
236
|
+
if self.layout_id != before_layout:
|
|
237
|
+
self.layout_id = before_layout
|
|
238
|
+
|
|
239
|
+
if before_layout in self.layout_ids:
|
|
240
|
+
ordered_ids = [layout_id for layout_id in self.layout_ids if layout_id != before_layout]
|
|
241
|
+
self.layout_ids = [before_layout, *ordered_ids]
|
|
242
|
+
else:
|
|
243
|
+
self.layout_ids = [before_layout, *self.layout_ids]
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class ParseTableRule(ParseRuleBase):
|
|
248
|
+
type: Literal[TestType.TABLE.value]
|
|
249
|
+
cell: str = ""
|
|
250
|
+
up: str | None = None
|
|
251
|
+
down: str | None = None
|
|
252
|
+
left: str | None = None
|
|
253
|
+
right: str | None = None
|
|
254
|
+
top_heading: str | None = None
|
|
255
|
+
left_heading: str | None = None
|
|
256
|
+
ignore_markdown_tables: bool = False
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class ParseTablesValuesRule(ParseRuleBase):
|
|
260
|
+
type: Literal[TestType.TABLES_VALUES.value]
|
|
261
|
+
table_variations: list[Any] | None = None
|
|
262
|
+
json_path: str | None = None
|
|
263
|
+
table_match_threshold: float = 0.8
|
|
264
|
+
table_values_match_threshold: float = 0.9
|
|
265
|
+
add_check_num_rows_test: bool = True
|
|
266
|
+
add_check_num_cols_test: bool = True
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class ParseTablesNumRowsRule(ParseRuleBase):
|
|
270
|
+
type: Literal[TestType.TABLES_NUM_ROWS.value]
|
|
271
|
+
expected_num_rows: int = 0
|
|
272
|
+
actual_num_rows: int | None = None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class ParseTablesNumColsRule(ParseRuleBase):
|
|
276
|
+
type: Literal[TestType.TABLES_NUM_COLS.value]
|
|
277
|
+
expected_num_cols: int = 0
|
|
278
|
+
actual_num_cols: int | None = None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class ParseTableMarkerCellsRule(ParseRuleBase):
|
|
282
|
+
"""Require icon-like values to survive as cells in one parsed table."""
|
|
283
|
+
|
|
284
|
+
type: Literal[TestType.TABLE_MARKER_CELLS.value]
|
|
285
|
+
min_count: int = Field(default=2, ge=1)
|
|
286
|
+
min_distinct_rows: int = Field(default=2, ge=1)
|
|
287
|
+
min_distinct_columns: int = Field(default=1, ge=1)
|
|
288
|
+
marker_aliases: list[str] = Field(default_factory=list)
|
|
289
|
+
allow_repeated_ocr_glyphs: bool = True
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class ParseTableColspanRule(ParseRuleBase, _HasAnchorCells):
|
|
293
|
+
type: Literal[TestType.TABLE_COLSPAN.value]
|
|
294
|
+
cell: str = ""
|
|
295
|
+
expected_colspan: int = 1
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class ParseTableRowspanRule(ParseRuleBase, _HasAnchorCells):
|
|
299
|
+
type: Literal[TestType.TABLE_ROWSPAN.value]
|
|
300
|
+
cell: str = ""
|
|
301
|
+
expected_rowspan: int = 1
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class ParseTableSameRowRule(ParseRuleBase, _HasAnchorCells):
|
|
305
|
+
type: Literal[TestType.TABLE_SAME_ROW.value]
|
|
306
|
+
cell_a: str = ""
|
|
307
|
+
cell_b: str = ""
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class ParseTableSameColumnRule(ParseRuleBase, _HasAnchorCells):
|
|
311
|
+
type: Literal[TestType.TABLE_SAME_COLUMN.value]
|
|
312
|
+
cell_a: str = ""
|
|
313
|
+
cell_b: str = ""
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class ParseTableHeaderChainRule(ParseRuleBase, _HasAnchorCells):
|
|
317
|
+
type: Literal[TestType.TABLE_HEADER_CHAIN.value]
|
|
318
|
+
data_cell: str = ""
|
|
319
|
+
column_headers: list[Any] = Field(default_factory=list)
|
|
320
|
+
row_headers: list[Any] = Field(default_factory=list)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class ParseTableAdjacentUpRule(ParseRuleBase, _HasAnchorCells):
|
|
324
|
+
type: Literal[TestType.TABLE_ADJACENT_UP.value]
|
|
325
|
+
anchor_cell: str = ""
|
|
326
|
+
expected_neighbor: str = ""
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class ParseTableAdjacentDownRule(ParseRuleBase, _HasAnchorCells):
|
|
330
|
+
type: Literal[TestType.TABLE_ADJACENT_DOWN.value]
|
|
331
|
+
anchor_cell: str = ""
|
|
332
|
+
expected_neighbor: str = ""
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class ParseTableAdjacentLeftRule(ParseRuleBase, _HasAnchorCells):
|
|
336
|
+
type: Literal[TestType.TABLE_ADJACENT_LEFT.value]
|
|
337
|
+
anchor_cell: str = ""
|
|
338
|
+
expected_neighbor: str = ""
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class ParseTableAdjacentRightRule(ParseRuleBase, _HasAnchorCells):
|
|
342
|
+
type: Literal[TestType.TABLE_ADJACENT_RIGHT.value]
|
|
343
|
+
anchor_cell: str = ""
|
|
344
|
+
expected_neighbor: str = ""
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class ParseTableTopHeaderRule(ParseRuleBase, _HasAnchorCells):
|
|
348
|
+
type: Literal[TestType.TABLE_TOP_HEADER.value]
|
|
349
|
+
data_cell: str = ""
|
|
350
|
+
expected_header: str = ""
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class ParseTableLeftHeaderRule(ParseRuleBase, _HasAnchorCells):
|
|
354
|
+
type: Literal[TestType.TABLE_LEFT_HEADER.value]
|
|
355
|
+
data_cell: str = ""
|
|
356
|
+
expected_header: str = ""
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class ParseTableNoLeftRule(ParseRuleBase, _HasAnchorCells):
|
|
360
|
+
type: Literal[TestType.TABLE_NO_LEFT.value]
|
|
361
|
+
cell: str = ""
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class ParseTableNoRightRule(ParseRuleBase, _HasAnchorCells):
|
|
365
|
+
type: Literal[TestType.TABLE_NO_RIGHT.value]
|
|
366
|
+
cell: str = ""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class ParseTableNoAboveRule(ParseRuleBase, _HasAnchorCells):
|
|
370
|
+
type: Literal[TestType.TABLE_NO_ABOVE.value]
|
|
371
|
+
cell: str = ""
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class ParseTableNoBelowRule(ParseRuleBase, _HasAnchorCells):
|
|
375
|
+
type: Literal[TestType.TABLE_NO_BELOW.value]
|
|
376
|
+
cell: str = ""
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class ParseChartDataPointRule(ParseRuleBase):
|
|
380
|
+
type: Literal[TestType.CHART_DATA_POINT.value]
|
|
381
|
+
value: str | int | float | None = None
|
|
382
|
+
labels: list[str] = Field(default_factory=list)
|
|
383
|
+
normalize_numbers: bool = True
|
|
384
|
+
relative_tolerance: float = 0.01
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class ParseChartDataArrayLabelsRule(ParseRuleBase):
|
|
388
|
+
type: Literal[TestType.CHART_DATA_ARRAY_LABELS.value]
|
|
389
|
+
data: list[Any] = Field(default_factory=list)
|
|
390
|
+
x_axis_shuffle: bool = False
|
|
391
|
+
transposed: bool = False
|
|
392
|
+
csv_path: str | None = Field(default=None, alias="_csv_path")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class ParseChartDataArrayDataRule(ParseRuleBase):
|
|
396
|
+
type: Literal[TestType.CHART_DATA_ARRAY_DATA.value]
|
|
397
|
+
data: list[Any] = Field(default_factory=list)
|
|
398
|
+
x_axis_shuffle: bool = False
|
|
399
|
+
y_axis_shuffle: bool = False
|
|
400
|
+
normalize_numbers: bool = True
|
|
401
|
+
transposed: bool = False
|
|
402
|
+
csv_path: str | None = Field(default=None, alias="_csv_path")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class ParseFormFieldRule(ParseRuleBase):
|
|
406
|
+
"""Schema for `form_field` rules.
|
|
407
|
+
|
|
408
|
+
Locates a labeled field in the parsed markdown/HTML and checks its value.
|
|
409
|
+
Used for benchmarking form (key-value, checkbox, signature) extraction.
|
|
410
|
+
|
|
411
|
+
`label` may be a list of strings for table-like form cells where one
|
|
412
|
+
value is identified by multiple visible keys (for example a row label
|
|
413
|
+
plus a column label). Label-list order is not significant.
|
|
414
|
+
|
|
415
|
+
For repeated yes/no checkbox groups, use a label list containing the
|
|
416
|
+
question and option text, e.g. `["Multistage cement?", "No"]`, with
|
|
417
|
+
`value_type: "checkbox"` and `value: true` or `false`.
|
|
418
|
+
|
|
419
|
+
For repeated row fields such as open-hole intervals, use the existing
|
|
420
|
+
row-index form, e.g. `"FROM (row 1)"` and `"TO (row 1)"`. This works for
|
|
421
|
+
markdown tables and flattened parser output such as `FROM none TO RRC`.
|
|
422
|
+
|
|
423
|
+
`label_max_diffs` may be a number or a list aligned with `label` to allow
|
|
424
|
+
limited OCR/handwriting variation in key text after normalization.
|
|
425
|
+
`value_max_diffs` is the equivalent tolerance for the extracted value.
|
|
426
|
+
|
|
427
|
+
`value` may be a list of strings to declare acceptable alternatives for
|
|
428
|
+
genuinely ambiguous fields (e.g. illegible handwriting). The evaluator
|
|
429
|
+
passes if the parsed value matches *any* alternative. Use sparingly —
|
|
430
|
+
most rules should be a single string. List form is only for value_type
|
|
431
|
+
"text" and "signature".
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
type: Literal[TestType.FORM_FIELD.value]
|
|
435
|
+
label: str | list[str] = ""
|
|
436
|
+
label_max_diffs: int | float | list[int | float] = 0
|
|
437
|
+
value_max_diffs: int | float = 0
|
|
438
|
+
value: str | bool | list[str] = ""
|
|
439
|
+
value_type: Literal["text", "checkbox", "signature"] = "text"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class ParseFormattingRule(ParseRuleBase):
|
|
443
|
+
type: Literal[
|
|
444
|
+
TestType.IS_UNDERLINE.value,
|
|
445
|
+
TestType.IS_NOT_UNDERLINE.value,
|
|
446
|
+
TestType.IS_BOLD.value,
|
|
447
|
+
TestType.IS_NOT_BOLD.value,
|
|
448
|
+
TestType.IS_STRIKEOUT.value,
|
|
449
|
+
TestType.IS_NOT_STRIKEOUT.value,
|
|
450
|
+
TestType.IS_ITALIC.value,
|
|
451
|
+
TestType.IS_NOT_ITALIC.value,
|
|
452
|
+
TestType.IS_MARK.value,
|
|
453
|
+
TestType.IS_NOT_MARK.value,
|
|
454
|
+
TestType.IS_SUP.value,
|
|
455
|
+
TestType.IS_NOT_SUP.value,
|
|
456
|
+
TestType.IS_SUB.value,
|
|
457
|
+
TestType.IS_NOT_SUB.value,
|
|
458
|
+
]
|
|
459
|
+
text: str = ""
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class ParseMarkColorRule(ParseRuleBase):
|
|
463
|
+
"""Schema for `mark_color` rules.
|
|
464
|
+
|
|
465
|
+
Validates that text is wrapped in a ``<mark>`` tag AND that the tag
|
|
466
|
+
contains the expected color string in one of its attributes
|
|
467
|
+
(e.g. ``style="background-color: yellow"``, ``background="yellow"``,
|
|
468
|
+
``backgroundColor="yellow"``).
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
type: Literal[TestType.MARK_COLOR.value]
|
|
472
|
+
text: str = ""
|
|
473
|
+
color: str = ""
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class ParseAbsentUnlessStrikeoutRule(ParseRuleBase):
|
|
477
|
+
"""Schema for `absent_unless_strikeout` rules.
|
|
478
|
+
|
|
479
|
+
For text deleted in a revision (struck through in the source document):
|
|
480
|
+
the parse should either omit the text entirely, or keep it explicitly
|
|
481
|
+
marked as struck (``~~text~~``, ``<s>``, ``<del>``, ``<strike>`` or a
|
|
482
|
+
``text-decoration: line-through`` style). The rule FAILS when the text
|
|
483
|
+
appears as regular, unmarked content — the failure mode where deleted
|
|
484
|
+
contract language becomes indistinguishable from current language.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
type: Literal[TestType.ABSENT_UNLESS_STRIKEOUT.value]
|
|
488
|
+
text: str = ""
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class ParsePresentAsStrikeoutRule(ParseRuleBase):
|
|
492
|
+
"""Schema for `present_as_strikeout` rules.
|
|
493
|
+
|
|
494
|
+
Stricter counterpart of `absent_unless_strikeout`: deleted (struck)
|
|
495
|
+
source text must be RETAINED in the output AND sit inside explicit
|
|
496
|
+
strikeout markup. Fails both when the text is dropped entirely and when
|
|
497
|
+
it appears only as plain content. Region-based like
|
|
498
|
+
`absent_unless_strikeout`, so wider-than-GT struck spans pass.
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
type: Literal[TestType.PRESENT_AS_STRIKEOUT.value]
|
|
502
|
+
text: str = ""
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class ParseTextColorRule(ParseRuleBase):
|
|
506
|
+
"""Schema for `text_color` rules.
|
|
507
|
+
|
|
508
|
+
Validates that text appears inside an HTML tag whose attributes carry a
|
|
509
|
+
text color matching the expected color family (e.g. ``red``, ``blue``,
|
|
510
|
+
``green``, ``orange``). Attribute colors may be named colors, hex codes
|
|
511
|
+
(``#c69200``) or ``rgb()`` values; matching is done by hue family, not
|
|
512
|
+
exact value, so ``#ffa6a6`` satisfies ``red``.
|
|
513
|
+
|
|
514
|
+
Primary use: colored revision text in legal redlines, where color encodes
|
|
515
|
+
revision semantics (e.g. red struck = deleted, blue underlined = inserted).
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
type: Literal[TestType.TEXT_COLOR.value]
|
|
519
|
+
text: str = ""
|
|
520
|
+
color: str = ""
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class ParseTitleRule(ParseRuleBase):
|
|
524
|
+
type: Literal[TestType.IS_TITLE.value]
|
|
525
|
+
text: str = ""
|
|
526
|
+
level: int | None = None
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class ParseListLevelRule(ParseRuleBase):
|
|
530
|
+
"""Schema for `list_level` rules.
|
|
531
|
+
|
|
532
|
+
Validates that `text` appears as a list item at nesting level `level`
|
|
533
|
+
(1 = top level). Levels are computed from the output with CommonMark
|
|
534
|
+
list-item semantics (https://spec.commonmark.org/0.31.2/#list-items): a
|
|
535
|
+
nested item's marker must sit at or beyond the column of the first content
|
|
536
|
+
character of the item it nests under - there is no fixed space count.
|
|
537
|
+
HTML `<ul>/<ol>/<li>` nesting is accepted as equivalent. Deliberately not
|
|
538
|
+
evaluated for indentation inside table cells.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
type: Literal[TestType.LIST_LEVEL.value]
|
|
542
|
+
text: str = ""
|
|
543
|
+
level: int | None = None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class ParseLatexRule(ParseRuleBase):
|
|
547
|
+
type: Literal[TestType.IS_LATEX.value]
|
|
548
|
+
formula: str
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class ParseNotLatexRule(ParseRuleBase):
|
|
552
|
+
"""Formula-like text that must remain ordinary Markdown."""
|
|
553
|
+
|
|
554
|
+
type: Literal[TestType.IS_NOT_LATEX.value]
|
|
555
|
+
text: str
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class ParseCodeBlockRule(ParseRuleBase):
|
|
559
|
+
type: Literal[TestType.IS_CODE_BLOCK.value]
|
|
560
|
+
language: str = ""
|
|
561
|
+
code: str = ""
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class ParseTitleHierarchyPercentRule(ParseRuleBase):
|
|
565
|
+
type: Literal[TestType.TITLE_HIERARCHY_PERCENT.value]
|
|
566
|
+
title_hierarchy: dict[str, Any] = Field(default_factory=dict)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
class HeadingExpectation(DictCompatPydanticModel):
|
|
570
|
+
"""One visible page heading in reading order."""
|
|
571
|
+
|
|
572
|
+
text: str = Field(min_length=1)
|
|
573
|
+
level: int = Field(ge=1, le=6)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
class ParseHeadingStructureRule(ParseRuleBase):
|
|
577
|
+
"""Complete ordered heading inventory for one transcribed page."""
|
|
578
|
+
|
|
579
|
+
type: Literal[TestType.HEADING_STRUCTURE.value]
|
|
580
|
+
# Empty is a meaningful negative annotation, so callers must state it
|
|
581
|
+
# explicitly instead of silently getting a no-headings rule after a typo.
|
|
582
|
+
headings: list[HeadingExpectation]
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
class ParsePageSectionRule(ParseRuleBase):
|
|
586
|
+
type: Literal[TestType.IS_HEADER.value, TestType.IS_FOOTER.value]
|
|
587
|
+
text: str = ""
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
class ParseBagOfDigitPercentRule(ParseRuleBase):
|
|
591
|
+
"""Schema for `bag_of_digit_percent` rule.
|
|
592
|
+
|
|
593
|
+
Compares digit frequency (0-9) between expected markdown and actual output.
|
|
594
|
+
Digits inside HTML tag attributes (e.g. colspan="2") are excluded.
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
type: Literal[TestType.BAG_OF_DIGIT_PERCENT.value]
|
|
598
|
+
bag_of_digit: dict[str, int] = Field(default_factory=dict)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class ParseRotateCheckRule(ParseRuleBase):
|
|
602
|
+
type: Literal[TestType.ROTATE_CHECK.value]
|
|
603
|
+
value: int | float | str | None = None
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
class ParseWatermarkRemovalRule(ParseRuleBase):
|
|
607
|
+
"""Score watermark suppression without rewarding destroyed body text."""
|
|
608
|
+
|
|
609
|
+
type: Literal[TestType.WATERMARK_REMOVAL.value]
|
|
610
|
+
page: int = Field(ge=1)
|
|
611
|
+
watermark_texts: list[str] = Field(min_length=1)
|
|
612
|
+
allowed_occurrences: list[int] | None = Field(
|
|
613
|
+
default=None,
|
|
614
|
+
description=(
|
|
615
|
+
"Maximum output occurrences allowed for each watermark phrase. Use this when the same phrase "
|
|
616
|
+
"also occurs legitimately in the page body; defaults to zero for every phrase."
|
|
617
|
+
),
|
|
618
|
+
)
|
|
619
|
+
preserve_texts: list[str] = Field(min_length=1)
|
|
620
|
+
watermark_match_threshold: float = Field(default=0.80, ge=0.0, le=1.0)
|
|
621
|
+
preserve_match_threshold: float = Field(default=0.85, ge=0.0, le=1.0)
|
|
622
|
+
removal_pass_threshold: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
623
|
+
preservation_pass_threshold: float = Field(default=0.80, ge=0.0, le=1.0)
|
|
624
|
+
|
|
625
|
+
@model_validator(mode="after")
|
|
626
|
+
def validate_allowed_occurrences(self) -> ParseWatermarkRemovalRule:
|
|
627
|
+
if self.allowed_occurrences is None:
|
|
628
|
+
return self
|
|
629
|
+
if len(self.allowed_occurrences) != len(self.watermark_texts):
|
|
630
|
+
raise ValueError("allowed_occurrences must have one value per watermark_texts entry")
|
|
631
|
+
if any(value < 0 for value in self.allowed_occurrences):
|
|
632
|
+
raise ValueError("allowed_occurrences values must be non-negative")
|
|
633
|
+
return self
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class ParseDiagramGraphRule(ParseRuleBase):
|
|
637
|
+
"""One diagram the parser must render as a mermaid block whose *graph* matches ``graph``.
|
|
638
|
+
|
|
639
|
+
Mermaid source is never compared: both sides are parsed into labelled nodes and edges,
|
|
640
|
+
nodes are aligned by fuzzy label similarity (Hungarian assignment) and edges are scored on
|
|
641
|
+
the aligned node ids. ``graph`` is ``{"nodes": [{"id","label"}], "edges": [{"from","to",
|
|
642
|
+
"label"?, "directed"?}], "groups"?: [{"id","label","members"}]}``. ``bbox`` is normalized
|
|
643
|
+
COCO ``[x, y, w, h]`` in page fractions, as for inline images.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
type: Literal[TestType.DIAGRAM_GRAPH.value]
|
|
647
|
+
graph: dict[str, Any] = Field(default_factory=dict, description="Ground-truth graph: nodes, edges, optional groups")
|
|
648
|
+
diagram_class: str = Field(default="flowchart", description="Annotated kind (flowchart, org_chart, process, ...)")
|
|
649
|
+
accepted_types: list[str] = Field(
|
|
650
|
+
default_factory=lambda: ["flowchart"],
|
|
651
|
+
description="Mermaid diagram types that may represent this figure (flowchart, state, sequence, mindmap, ...)",
|
|
652
|
+
)
|
|
653
|
+
bbox: list[float] = Field(default_factory=list, description="Normalized [x, y, w, h] of the diagram on the page")
|
|
654
|
+
caption: str | None = Field(default=None, description="Verbatim caption printed next to the diagram, if any")
|
|
655
|
+
reference_image: str | None = Field(default=None, description="Crop PNG next to the test case, for the report")
|
|
656
|
+
text_before: list[str] = Field(default_factory=list, description="Text lines just above the diagram, reading order")
|
|
657
|
+
text_after: list[str] = Field(default_factory=list, description="Text lines just below the diagram, reading order")
|
|
658
|
+
annotator_confidence: float | None = Field(default=None, description="Annotator confidence in the graph")
|
|
659
|
+
node_match_threshold: int = Field(
|
|
660
|
+
default=80, description="rapidfuzz token_set_ratio at or above which two labels align"
|
|
661
|
+
)
|
|
662
|
+
node_recall_threshold: float = Field(default=0.80, description="Minimum share of expected nodes found")
|
|
663
|
+
node_precision_threshold: float = Field(
|
|
664
|
+
default=0.70, description="Minimum share of predicted nodes that are expected"
|
|
665
|
+
)
|
|
666
|
+
edge_f1_threshold: float = Field(default=0.60, description="Minimum edge F1 over aligned nodes")
|
|
667
|
+
edge_label_threshold: int = Field(default=70, description="Fuzzy ratio for edge labels when the reference has one")
|
|
668
|
+
|
|
669
|
+
@field_validator("bbox")
|
|
670
|
+
@classmethod
|
|
671
|
+
def _validate_bbox(cls, v: list[float]) -> list[float]:
|
|
672
|
+
if v and (len(v) != 4 or any(not isinstance(x, (int, float)) for x in v)):
|
|
673
|
+
raise ValueError("bbox must be [x, y, w, h]")
|
|
674
|
+
return [float(x) for x in v]
|
|
675
|
+
|
|
676
|
+
@field_validator("graph")
|
|
677
|
+
@classmethod
|
|
678
|
+
def _validate_graph(cls, v: dict[str, Any]) -> dict[str, Any]:
|
|
679
|
+
nodes = v.get("nodes") or []
|
|
680
|
+
ids = {str(n.get("id")) for n in nodes if isinstance(n, dict)}
|
|
681
|
+
if not ids:
|
|
682
|
+
raise ValueError("graph must declare at least one node")
|
|
683
|
+
for e in v.get("edges") or []:
|
|
684
|
+
if str(e.get("from")) not in ids or str(e.get("to")) not in ids:
|
|
685
|
+
raise ValueError(f"edge {e} references an unknown node id")
|
|
686
|
+
return v
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
class ParseDiagramEdgeRule(ParseRuleBase):
|
|
690
|
+
"""One relation that must (or must not) hold between two labelled nodes of a mermaid diagram.
|
|
691
|
+
|
|
692
|
+
Anchors are the robust complement to the graph rule: they are cheap to annotate, easy to
|
|
693
|
+
verify by eye, and insensitive to how creatively the rest of the diagram was transcribed.
|
|
694
|
+
``relation`` is ``edge`` (a direct link source→target), ``path`` (source reaches target
|
|
695
|
+
through any directed chain, tolerating inserted intermediate nodes) or ``no_edge``.
|
|
696
|
+
"""
|
|
697
|
+
|
|
698
|
+
type: Literal[TestType.DIAGRAM_EDGE.value]
|
|
699
|
+
source: str = Field(description="Label of the source node")
|
|
700
|
+
target: str = Field(description="Label of the target node")
|
|
701
|
+
relation: Literal["edge", "path", "no_edge"] = Field(default="edge")
|
|
702
|
+
label: str | None = Field(default=None, description="Expected edge text (e.g. Yes / No), fuzzy-matched when set")
|
|
703
|
+
directed: bool = Field(default=True, description="False accepts the link in either direction")
|
|
704
|
+
diagram_id: str | None = Field(default=None, description="``id`` of the diagram_graph rule this anchor belongs to")
|
|
705
|
+
node_match_threshold: int = Field(default=80, description="rapidfuzz token_set_ratio to identify source/target")
|
|
706
|
+
edge_label_threshold: int = Field(default=70, description="Fuzzy ratio for the edge label when set")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
class ParseDiagramCountRule(ParseRuleBase):
|
|
710
|
+
"""Page-level decision: the right number of *parseable* mermaid blocks, none where none belong.
|
|
711
|
+
|
|
712
|
+
``expected_count`` is 0 on negative pages (charts, schematics, maps, infographics) where any
|
|
713
|
+
mermaid block fails the rule. Blocks that do not parse into at least one node count against
|
|
714
|
+
the page everywhere.
|
|
715
|
+
"""
|
|
716
|
+
|
|
717
|
+
type: Literal[TestType.DIAGRAM_COUNT.value]
|
|
718
|
+
expected_count: int = Field(default=0, description="Number of mermaid blocks the page should carry")
|
|
719
|
+
strict: bool = Field(default=True, description="Penalise extra parseable blocks beyond expected_count")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
class ParsePageDecorationRule(ParseRuleBase):
|
|
723
|
+
"""Running header, running footer and printed page number of one page.
|
|
724
|
+
|
|
725
|
+
``None`` for a slot means the page has none and the parser must leave it empty; a string is
|
|
726
|
+
the verbatim furniture text (header/footer pieces joined by `` | `` in reading order). The
|
|
727
|
+
rule reads the parser's structured page fields when available, else the markdown tags, and
|
|
728
|
+
also checks that the furniture text does not remain in the body markdown.
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
type: Literal[TestType.PAGE_DECORATION.value]
|
|
732
|
+
header: str | None = Field(
|
|
733
|
+
default=None, description="Running header text, pieces joined by ' | '; None = no header"
|
|
734
|
+
)
|
|
735
|
+
footer: str | None = Field(
|
|
736
|
+
default=None, description="Running footer text, pieces joined by ' | '; None = no footer"
|
|
737
|
+
)
|
|
738
|
+
page_number: str | None = Field(default=None, description="Canonical printed page number (3, iv, A-3); None = none")
|
|
739
|
+
page_number_raw: str | None = Field(default=None, description="Page number as printed (Page 3 of 10), for leakage")
|
|
740
|
+
text_threshold: int = Field(default=85, description="token_set_ratio at or above which header/footer text matches")
|
|
741
|
+
leak_min_chars: int = Field(default=4, description="Furniture pieces shorter than this are not checked for leakage")
|
|
742
|
+
annotator_confidence: float | None = Field(default=None, description="Annotator confidence, informational")
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
type ParseRule = (
|
|
746
|
+
ParsePresenceRule
|
|
747
|
+
| ParseUnexpectedSentenceRule
|
|
748
|
+
| ParseUnexpectedSentencePercentRule
|
|
749
|
+
| ParseTooManySentenceOccurrenceRule
|
|
750
|
+
| ParseTooManySentenceOccurrencePercentRule
|
|
751
|
+
| ParseMissingSentenceRule
|
|
752
|
+
| ParseMissingSentencePercentRule
|
|
753
|
+
| ParseMissingSpecificSentenceRule
|
|
754
|
+
| ParseUnexpectedWordRule
|
|
755
|
+
| ParseUnexpectedWordPercentRule
|
|
756
|
+
| ParseTooManyWordOccurrenceRule
|
|
757
|
+
| ParseTooManyWordOccurrencePercentRule
|
|
758
|
+
| ParseMissingWordRule
|
|
759
|
+
| ParseMissingWordPercentRule
|
|
760
|
+
| ParseMissingSpecificWordRule
|
|
761
|
+
| ParseExtraContentRule
|
|
762
|
+
| ParseBaselineRule
|
|
763
|
+
| ParseOrderRule
|
|
764
|
+
| ParseTableRule
|
|
765
|
+
| ParseTablesValuesRule
|
|
766
|
+
| ParseTablesNumRowsRule
|
|
767
|
+
| ParseTablesNumColsRule
|
|
768
|
+
| ParseTableMarkerCellsRule
|
|
769
|
+
| ParseTableColspanRule
|
|
770
|
+
| ParseTableRowspanRule
|
|
771
|
+
| ParseTableSameRowRule
|
|
772
|
+
| ParseTableSameColumnRule
|
|
773
|
+
| ParseTableHeaderChainRule
|
|
774
|
+
| ParseTableAdjacentUpRule
|
|
775
|
+
| ParseTableAdjacentDownRule
|
|
776
|
+
| ParseTableAdjacentLeftRule
|
|
777
|
+
| ParseTableAdjacentRightRule
|
|
778
|
+
| ParseTableTopHeaderRule
|
|
779
|
+
| ParseTableLeftHeaderRule
|
|
780
|
+
| ParseTableNoLeftRule
|
|
781
|
+
| ParseTableNoRightRule
|
|
782
|
+
| ParseTableNoAboveRule
|
|
783
|
+
| ParseTableNoBelowRule
|
|
784
|
+
| ParseChartDataPointRule
|
|
785
|
+
| ParseChartDataArrayLabelsRule
|
|
786
|
+
| ParseChartDataArrayDataRule
|
|
787
|
+
| ParseFormFieldRule
|
|
788
|
+
| ParseFormattingRule
|
|
789
|
+
| ParseMarkColorRule
|
|
790
|
+
| ParseTextColorRule
|
|
791
|
+
| ParseAbsentUnlessStrikeoutRule
|
|
792
|
+
| ParsePresentAsStrikeoutRule
|
|
793
|
+
| ParseLatexRule
|
|
794
|
+
| ParseNotLatexRule
|
|
795
|
+
| ParseCodeBlockRule
|
|
796
|
+
| ParseTitleRule
|
|
797
|
+
| ParseTitleHierarchyPercentRule
|
|
798
|
+
| ParseHeadingStructureRule
|
|
799
|
+
| ParseListLevelRule
|
|
800
|
+
| ParsePageSectionRule
|
|
801
|
+
| ParseBagOfDigitPercentRule
|
|
802
|
+
| ParseRotateCheckRule
|
|
803
|
+
| ParseWatermarkRemovalRule
|
|
804
|
+
| ParseDiagramGraphRule
|
|
805
|
+
| ParseDiagramEdgeRule
|
|
806
|
+
| ParseDiagramCountRule
|
|
807
|
+
| ParsePageDecorationRule
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
type ParseRuleInput = ParseRule | dict[str, Any]
|
|
811
|
+
|
|
812
|
+
_RULE_TYPE_TO_MODEL: dict[str, type[ParseRuleBase]] = {
|
|
813
|
+
TestType.PRESENT.value: ParsePresenceRule,
|
|
814
|
+
TestType.ABSENT.value: ParsePresenceRule,
|
|
815
|
+
TestType.UNEXPECTED_SENTENCE.value: ParseUnexpectedSentenceRule,
|
|
816
|
+
TestType.UNEXPECTED_SENTENCE_PERCENT.value: ParseUnexpectedSentencePercentRule,
|
|
817
|
+
TestType.TOO_MANY_SENTENCE_OCCURENCE.value: ParseTooManySentenceOccurrenceRule,
|
|
818
|
+
TestType.TOO_MANY_SENTENCE_OCCURENCE_PERCENT.value: ParseTooManySentenceOccurrencePercentRule,
|
|
819
|
+
TestType.MISSING_SENTENCE.value: ParseMissingSentenceRule,
|
|
820
|
+
TestType.MISSING_SENTENCE_PERCENT.value: ParseMissingSentencePercentRule,
|
|
821
|
+
TestType.MISSING_SPECIFIC_SENTENCE.value: ParseMissingSpecificSentenceRule,
|
|
822
|
+
TestType.UNEXPECTED_WORD.value: ParseUnexpectedWordRule,
|
|
823
|
+
TestType.UNEXPECTED_WORD_PERCENT.value: ParseUnexpectedWordPercentRule,
|
|
824
|
+
TestType.TOO_MANY_WORD_OCCURENCE.value: ParseTooManyWordOccurrenceRule,
|
|
825
|
+
TestType.TOO_MANY_WORD_OCCURENCE_PERCENT.value: ParseTooManyWordOccurrencePercentRule,
|
|
826
|
+
TestType.MISSING_WORD.value: ParseMissingWordRule,
|
|
827
|
+
TestType.MISSING_WORD_PERCENT.value: ParseMissingWordPercentRule,
|
|
828
|
+
TestType.MISSING_SPECIFIC_WORD.value: ParseMissingSpecificWordRule,
|
|
829
|
+
TestType.EXTRA_CONTENT.value: ParseExtraContentRule,
|
|
830
|
+
TestType.BASELINE.value: ParseBaselineRule,
|
|
831
|
+
TestType.ORDER.value: ParseOrderRule,
|
|
832
|
+
TestType.TABLE.value: ParseTableRule,
|
|
833
|
+
TestType.TABLES_VALUES.value: ParseTablesValuesRule,
|
|
834
|
+
TestType.TABLES_NUM_ROWS.value: ParseTablesNumRowsRule,
|
|
835
|
+
TestType.TABLES_NUM_COLS.value: ParseTablesNumColsRule,
|
|
836
|
+
TestType.TABLE_MARKER_CELLS.value: ParseTableMarkerCellsRule,
|
|
837
|
+
TestType.TABLE_COLSPAN.value: ParseTableColspanRule,
|
|
838
|
+
TestType.TABLE_ROWSPAN.value: ParseTableRowspanRule,
|
|
839
|
+
TestType.TABLE_SAME_ROW.value: ParseTableSameRowRule,
|
|
840
|
+
TestType.TABLE_SAME_COLUMN.value: ParseTableSameColumnRule,
|
|
841
|
+
TestType.TABLE_HEADER_CHAIN.value: ParseTableHeaderChainRule,
|
|
842
|
+
TestType.TABLE_ADJACENT_UP.value: ParseTableAdjacentUpRule,
|
|
843
|
+
TestType.TABLE_ADJACENT_DOWN.value: ParseTableAdjacentDownRule,
|
|
844
|
+
TestType.TABLE_ADJACENT_LEFT.value: ParseTableAdjacentLeftRule,
|
|
845
|
+
TestType.TABLE_ADJACENT_RIGHT.value: ParseTableAdjacentRightRule,
|
|
846
|
+
TestType.TABLE_TOP_HEADER.value: ParseTableTopHeaderRule,
|
|
847
|
+
TestType.TABLE_LEFT_HEADER.value: ParseTableLeftHeaderRule,
|
|
848
|
+
TestType.TABLE_NO_LEFT.value: ParseTableNoLeftRule,
|
|
849
|
+
TestType.TABLE_NO_RIGHT.value: ParseTableNoRightRule,
|
|
850
|
+
TestType.TABLE_NO_ABOVE.value: ParseTableNoAboveRule,
|
|
851
|
+
TestType.TABLE_NO_BELOW.value: ParseTableNoBelowRule,
|
|
852
|
+
TestType.CHART_DATA_POINT.value: ParseChartDataPointRule,
|
|
853
|
+
TestType.CHART_DATA_ARRAY_LABELS.value: ParseChartDataArrayLabelsRule,
|
|
854
|
+
TestType.CHART_DATA_ARRAY_DATA.value: ParseChartDataArrayDataRule,
|
|
855
|
+
TestType.FORM_FIELD.value: ParseFormFieldRule,
|
|
856
|
+
TestType.IS_UNDERLINE.value: ParseFormattingRule,
|
|
857
|
+
TestType.IS_NOT_UNDERLINE.value: ParseFormattingRule,
|
|
858
|
+
TestType.IS_BOLD.value: ParseFormattingRule,
|
|
859
|
+
TestType.IS_NOT_BOLD.value: ParseFormattingRule,
|
|
860
|
+
TestType.IS_STRIKEOUT.value: ParseFormattingRule,
|
|
861
|
+
TestType.IS_NOT_STRIKEOUT.value: ParseFormattingRule,
|
|
862
|
+
TestType.IS_ITALIC.value: ParseFormattingRule,
|
|
863
|
+
TestType.IS_NOT_ITALIC.value: ParseFormattingRule,
|
|
864
|
+
TestType.IS_MARK.value: ParseFormattingRule,
|
|
865
|
+
TestType.IS_NOT_MARK.value: ParseFormattingRule,
|
|
866
|
+
TestType.MARK_COLOR.value: ParseMarkColorRule,
|
|
867
|
+
TestType.TEXT_COLOR.value: ParseTextColorRule,
|
|
868
|
+
TestType.ABSENT_UNLESS_STRIKEOUT.value: ParseAbsentUnlessStrikeoutRule,
|
|
869
|
+
TestType.PRESENT_AS_STRIKEOUT.value: ParsePresentAsStrikeoutRule,
|
|
870
|
+
TestType.IS_SUP.value: ParseFormattingRule,
|
|
871
|
+
TestType.IS_NOT_SUP.value: ParseFormattingRule,
|
|
872
|
+
TestType.IS_SUB.value: ParseFormattingRule,
|
|
873
|
+
TestType.IS_NOT_SUB.value: ParseFormattingRule,
|
|
874
|
+
TestType.IS_LATEX.value: ParseLatexRule,
|
|
875
|
+
TestType.IS_NOT_LATEX.value: ParseNotLatexRule,
|
|
876
|
+
TestType.IS_CODE_BLOCK.value: ParseCodeBlockRule,
|
|
877
|
+
TestType.IS_TITLE.value: ParseTitleRule,
|
|
878
|
+
TestType.TITLE_HIERARCHY_PERCENT.value: ParseTitleHierarchyPercentRule,
|
|
879
|
+
TestType.HEADING_STRUCTURE.value: ParseHeadingStructureRule,
|
|
880
|
+
TestType.LIST_LEVEL.value: ParseListLevelRule,
|
|
881
|
+
TestType.IS_HEADER.value: ParsePageSectionRule,
|
|
882
|
+
TestType.IS_FOOTER.value: ParsePageSectionRule,
|
|
883
|
+
TestType.BAG_OF_DIGIT_PERCENT.value: ParseBagOfDigitPercentRule,
|
|
884
|
+
TestType.ROTATE_CHECK.value: ParseRotateCheckRule,
|
|
885
|
+
TestType.WATERMARK_REMOVAL.value: ParseWatermarkRemovalRule,
|
|
886
|
+
TestType.DIAGRAM_GRAPH.value: ParseDiagramGraphRule,
|
|
887
|
+
TestType.DIAGRAM_EDGE.value: ParseDiagramEdgeRule,
|
|
888
|
+
TestType.DIAGRAM_COUNT.value: ParseDiagramCountRule,
|
|
889
|
+
TestType.PAGE_DECORATION.value: ParsePageDecorationRule,
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
_BUILTIN_RULE_TYPES: frozenset[str] = frozenset(_RULE_TYPE_TO_MODEL)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def get_rule_type(rule: ParseRuleInput | Any) -> str | None:
|
|
896
|
+
"""Return the `type` field from either a typed schema or raw dict."""
|
|
897
|
+
|
|
898
|
+
if isinstance(rule, ParseRuleBase):
|
|
899
|
+
return rule.type
|
|
900
|
+
if isinstance(rule, BaseModel):
|
|
901
|
+
value = getattr(rule, "type", None)
|
|
902
|
+
return value if isinstance(value, str) else None
|
|
903
|
+
if isinstance(rule, dict):
|
|
904
|
+
raw_type = rule.get("type")
|
|
905
|
+
return raw_type if isinstance(raw_type, str) else None
|
|
906
|
+
return None
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def get_rule_id(rule: ParseRuleInput | Any) -> str | None:
|
|
910
|
+
"""Return optional `id` from either typed schema or raw dict."""
|
|
911
|
+
|
|
912
|
+
if isinstance(rule, ParseRuleBase):
|
|
913
|
+
return rule.id
|
|
914
|
+
if isinstance(rule, BaseModel):
|
|
915
|
+
value = getattr(rule, "id", None)
|
|
916
|
+
return value if isinstance(value, str) else None
|
|
917
|
+
if isinstance(rule, dict):
|
|
918
|
+
value = rule.get("id")
|
|
919
|
+
return value if isinstance(value, str) else None
|
|
920
|
+
return None
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def get_rule_page(rule: ParseRuleInput | Any) -> int | None:
|
|
924
|
+
"""Return optional `page` from either typed schema or raw dict."""
|
|
925
|
+
|
|
926
|
+
if isinstance(rule, ParseRuleBase):
|
|
927
|
+
return rule.page
|
|
928
|
+
if isinstance(rule, BaseModel):
|
|
929
|
+
value = getattr(rule, "page", None)
|
|
930
|
+
return value if isinstance(value, int) else None
|
|
931
|
+
if isinstance(rule, dict):
|
|
932
|
+
value = rule.get("page")
|
|
933
|
+
return value if isinstance(value, int) else None
|
|
934
|
+
return None
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def get_rule_layout_id(rule: ParseRuleInput | Any) -> str | None:
|
|
938
|
+
"""Return primary `layout_id` from either typed schema or raw dict."""
|
|
939
|
+
|
|
940
|
+
if isinstance(rule, ParseRuleBase):
|
|
941
|
+
return rule.layout_id
|
|
942
|
+
if isinstance(rule, BaseModel):
|
|
943
|
+
value = getattr(rule, "layout_id", None)
|
|
944
|
+
if isinstance(value, str):
|
|
945
|
+
return value
|
|
946
|
+
layout_ids = get_rule_layout_ids(rule)
|
|
947
|
+
return layout_ids[0] if layout_ids else None
|
|
948
|
+
if isinstance(rule, dict):
|
|
949
|
+
value = rule.get("layout_id")
|
|
950
|
+
if isinstance(value, str):
|
|
951
|
+
return value
|
|
952
|
+
layout_ids = get_rule_layout_ids(rule)
|
|
953
|
+
return layout_ids[0] if layout_ids else None
|
|
954
|
+
return None
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def get_rule_layout_ids(rule: ParseRuleInput | Any) -> list[str]:
|
|
958
|
+
"""Return normalized `layout_ids` from either typed schema or raw dict."""
|
|
959
|
+
|
|
960
|
+
if isinstance(rule, ParseRuleBase):
|
|
961
|
+
return list(rule.layout_ids)
|
|
962
|
+
if isinstance(rule, BaseModel):
|
|
963
|
+
value = getattr(rule, "layout_ids", None)
|
|
964
|
+
if isinstance(value, list):
|
|
965
|
+
return [v for v in value if isinstance(v, str)]
|
|
966
|
+
return []
|
|
967
|
+
if isinstance(rule, dict):
|
|
968
|
+
value = rule.get("layout_ids")
|
|
969
|
+
if isinstance(value, list):
|
|
970
|
+
return [v for v in value if isinstance(v, str)]
|
|
971
|
+
fallback = rule.get("layout_id")
|
|
972
|
+
if isinstance(fallback, str):
|
|
973
|
+
return [fallback]
|
|
974
|
+
return []
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def get_rule_layout_bindings(rule: ParseRuleInput | Any) -> dict[str, str | list[str]]:
|
|
978
|
+
"""Return `layout_bindings` from either typed schema or raw dict."""
|
|
979
|
+
|
|
980
|
+
if isinstance(rule, ParseRuleBase):
|
|
981
|
+
return dict(rule.layout_bindings)
|
|
982
|
+
|
|
983
|
+
def _is_valid_binding_value(value: Any) -> bool:
|
|
984
|
+
return isinstance(value, str) or (isinstance(value, list) and all(isinstance(item, str) for item in value))
|
|
985
|
+
|
|
986
|
+
if isinstance(rule, BaseModel):
|
|
987
|
+
value = getattr(rule, "layout_bindings", None)
|
|
988
|
+
if isinstance(value, dict):
|
|
989
|
+
return {str(k): v for k, v in value.items() if _is_valid_binding_value(v)}
|
|
990
|
+
return {}
|
|
991
|
+
if isinstance(rule, dict):
|
|
992
|
+
value = rule.get("layout_bindings")
|
|
993
|
+
if isinstance(value, dict):
|
|
994
|
+
return {str(k): v for k, v in value.items() if _is_valid_binding_value(v)}
|
|
995
|
+
return {}
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def rule_to_dict(rule: ParseRuleInput | Any) -> dict[str, Any]:
|
|
999
|
+
"""Serialize a typed or raw rule to a mutable dict for reporting/evals."""
|
|
1000
|
+
|
|
1001
|
+
if isinstance(rule, BaseModel):
|
|
1002
|
+
return rule.model_dump(by_alias=True)
|
|
1003
|
+
if isinstance(rule, dict):
|
|
1004
|
+
return dict(rule)
|
|
1005
|
+
raise TypeError(f"Expected ParseRule or dict, got {type(rule)!r}")
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def register_parse_rule_model(rule_type: str, model: type[ParseRuleBase]) -> None:
|
|
1009
|
+
"""Register the pydantic schema for an additional rule ``type``.
|
|
1010
|
+
|
|
1011
|
+
Extensions pair this with ``rules_base.register_rule_class`` (or call
|
|
1012
|
+
``parse_bench.extensions.register_rule_type`` which does both). Built-in
|
|
1013
|
+
rule types cannot be replaced.
|
|
1014
|
+
"""
|
|
1015
|
+
if not isinstance(rule_type, str) or not rule_type:
|
|
1016
|
+
raise ValueError("rule_type must be a non-empty string")
|
|
1017
|
+
if not (isinstance(model, type) and issubclass(model, ParseRuleBase)):
|
|
1018
|
+
raise TypeError("model must subclass ParseRuleBase")
|
|
1019
|
+
existing = _RULE_TYPE_TO_MODEL.get(rule_type)
|
|
1020
|
+
if existing is not None and existing is not model and rule_type in _BUILTIN_RULE_TYPES:
|
|
1021
|
+
raise ValueError(f"{rule_type!r} is a built-in rule type and cannot be replaced")
|
|
1022
|
+
_RULE_TYPE_TO_MODEL[rule_type] = model
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def registered_parse_rule_types() -> list[str]:
|
|
1026
|
+
"""Every rule ``type`` with a registered schema (built-ins and extensions)."""
|
|
1027
|
+
return list(_RULE_TYPE_TO_MODEL)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def coerce_parse_rule(rule_data: ParseRuleInput | Any) -> ParseRuleBase:
|
|
1031
|
+
"""Coerce raw rule payloads into the typed parse rule model.
|
|
1032
|
+
|
|
1033
|
+
Idempotent: already-typed rules are returned as-is.
|
|
1034
|
+
"""
|
|
1035
|
+
|
|
1036
|
+
if isinstance(rule_data, ParseRuleBase):
|
|
1037
|
+
return rule_data # type: ignore[return-value]
|
|
1038
|
+
|
|
1039
|
+
if isinstance(rule_data, dict):
|
|
1040
|
+
rule_dict = rule_data
|
|
1041
|
+
else:
|
|
1042
|
+
raise TypeError(f"Expected rule payload as dict or ParseRule, got {type(rule_data)!r}")
|
|
1043
|
+
|
|
1044
|
+
rule_type = rule_dict.get("type")
|
|
1045
|
+
if not isinstance(rule_type, str):
|
|
1046
|
+
raise ValueError("Rule payload must contain a string 'type' field")
|
|
1047
|
+
|
|
1048
|
+
model_cls = _RULE_TYPE_TO_MODEL.get(rule_type)
|
|
1049
|
+
if model_cls is None:
|
|
1050
|
+
raise ValueError(f"Unknown parse rule type: {rule_type}")
|
|
1051
|
+
|
|
1052
|
+
return model_cls.model_validate(rule_dict)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def coerce_parse_rule_list(rules: list[dict[str, Any]] | list[ParseRule]) -> list[ParseRuleBase]:
|
|
1056
|
+
"""Validate a list of parse rule payloads and return typed objects."""
|
|
1057
|
+
|
|
1058
|
+
parsed: list[ParseRule] = []
|
|
1059
|
+
for rule in rules:
|
|
1060
|
+
parsed.append(coerce_parse_rule(rule))
|
|
1061
|
+
return parsed
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def coerce_parse_rule_list_or_none(
|
|
1065
|
+
rules: list[dict[str, Any]] | list[ParseRule] | None,
|
|
1066
|
+
) -> list[ParseRuleBase] | None:
|
|
1067
|
+
"""Coerce rule lists while preserving explicit None for optional fields."""
|
|
1068
|
+
|
|
1069
|
+
if rules is None:
|
|
1070
|
+
return None
|
|
1071
|
+
return coerce_parse_rule_list(rules)
|