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,1161 @@
|
|
|
1
|
+
"""Sentence, word, and digit bag test rules."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from parse_bench.evaluation.metrics.parse.rules_base import (
|
|
10
|
+
ParseTestRule,
|
|
11
|
+
SentenceBagRuleData,
|
|
12
|
+
WordBagRuleData,
|
|
13
|
+
_augment_with_table_cell_text,
|
|
14
|
+
_strip_and_replace_latex,
|
|
15
|
+
_strip_fenced_code_blocks,
|
|
16
|
+
_strip_html_tables_and_content,
|
|
17
|
+
_unescape_html_entities,
|
|
18
|
+
)
|
|
19
|
+
from parse_bench.evaluation.metrics.parse.test_types import TestType
|
|
20
|
+
from parse_bench.evaluation.metrics.parse.utils import (
|
|
21
|
+
NON_WORD_CHAR_CLASS,
|
|
22
|
+
WORD_CHAR_CLASS,
|
|
23
|
+
normalize_text,
|
|
24
|
+
)
|
|
25
|
+
from parse_bench.test_cases.parse_rule_schemas import (
|
|
26
|
+
ParseBagOfDigitPercentRule,
|
|
27
|
+
ParseExtraContentRule,
|
|
28
|
+
ParseMissingSentencePercentRule,
|
|
29
|
+
ParseMissingSentenceRule,
|
|
30
|
+
ParseMissingSpecificSentenceRule,
|
|
31
|
+
ParseMissingSpecificWordRule,
|
|
32
|
+
ParseMissingWordPercentRule,
|
|
33
|
+
ParseMissingWordRule,
|
|
34
|
+
ParseTooManySentenceOccurrencePercentRule,
|
|
35
|
+
ParseTooManySentenceOccurrenceRule,
|
|
36
|
+
ParseTooManyWordOccurrencePercentRule,
|
|
37
|
+
ParseTooManyWordOccurrenceRule,
|
|
38
|
+
ParseUnexpectedSentencePercentRule,
|
|
39
|
+
ParseUnexpectedSentenceRule,
|
|
40
|
+
ParseUnexpectedWordPercentRule,
|
|
41
|
+
ParseUnexpectedWordRule,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# Matches HTML tags including their attributes (e.g. <td colspan="2">)
|
|
47
|
+
_HTML_TAG_WITH_ATTRS_PATTERN = re.compile(r"<[^>]+>")
|
|
48
|
+
|
|
49
|
+
# CJK Unicode ranges for character-level word splitting
|
|
50
|
+
_CJK_RANGES = (
|
|
51
|
+
("\u4e00", "\u9fff"), # CJK Unified Ideographs
|
|
52
|
+
("\u3400", "\u4dbf"), # CJK Extension A
|
|
53
|
+
("\uf900", "\ufaff"), # CJK Compatibility Ideographs
|
|
54
|
+
("\u3040", "\u309f"), # Hiragana
|
|
55
|
+
("\u30a0", "\u30ff"), # Katakana
|
|
56
|
+
("\uac00", "\ud7af"), # Hangul Syllables
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_cjk_char(ch: str) -> bool:
|
|
61
|
+
"""Return True if *ch* is a CJK/Japanese/Korean character."""
|
|
62
|
+
return any(lo <= ch <= hi for lo, hi in _CJK_RANGES)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Unicode-aware word tokenization: matches sequences of Unicode letters,
|
|
66
|
+
# digits and combining marks, properly handling accented characters, CJK, etc.
|
|
67
|
+
# Combining marks are word characters: Python's ``\w`` excludes them, which cut
|
|
68
|
+
# Thai/Indic words at every vowel sign, tone mark and virama.
|
|
69
|
+
# Aligned with the JS annotation tool, which uses /[\p{L}\p{M}\p{N}]+/gu —
|
|
70
|
+
# consecutive CJK characters stay grouped as a single token.
|
|
71
|
+
_UNICODE_WORD_PATTERN = re.compile(rf"{WORD_CHAR_CLASS}+", re.UNICODE)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _tokenize_unicode_words(text: str, min_length: int = 2) -> list[str]:
|
|
75
|
+
"""Tokenize *text* into words using Unicode-aware rules.
|
|
76
|
+
|
|
77
|
+
- Latin/accented/Cyrillic text is split on word boundaries as usual.
|
|
78
|
+
- Consecutive CJK characters are kept together as a single token,
|
|
79
|
+
matching the JS annotation tool's ``/[\\p{L}\\p{N}]+/gu`` behaviour.
|
|
80
|
+
- Words shorter than *min_length* are discarded (CJK single characters
|
|
81
|
+
always pass regardless of *min_length*).
|
|
82
|
+
"""
|
|
83
|
+
raw_tokens = _UNICODE_WORD_PATTERN.findall(text)
|
|
84
|
+
words: list[str] = []
|
|
85
|
+
for token in raw_tokens:
|
|
86
|
+
has_cjk = any(_is_cjk_char(ch) for ch in token)
|
|
87
|
+
if has_cjk:
|
|
88
|
+
# Group consecutive CJK characters together; non-CJK runs are
|
|
89
|
+
# separate tokens. E.g. "検査abc結果" → ["検査", "abc", "結果"]
|
|
90
|
+
buf: list[str] = []
|
|
91
|
+
in_cjk = False
|
|
92
|
+
for ch in token:
|
|
93
|
+
is_cjk = _is_cjk_char(ch)
|
|
94
|
+
if is_cjk != in_cjk and buf:
|
|
95
|
+
run = "".join(buf)
|
|
96
|
+
if in_cjk or len(run) >= min_length:
|
|
97
|
+
words.append(run)
|
|
98
|
+
buf = []
|
|
99
|
+
buf.append(ch)
|
|
100
|
+
in_cjk = is_cjk
|
|
101
|
+
if buf:
|
|
102
|
+
run = "".join(buf)
|
|
103
|
+
if in_cjk or len(run) >= min_length:
|
|
104
|
+
words.append(run)
|
|
105
|
+
else:
|
|
106
|
+
if len(token) >= min_length:
|
|
107
|
+
words.append(token)
|
|
108
|
+
return words
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _word_boundary_count(word: str, text: str) -> int:
|
|
112
|
+
"""Count word-boundary-delimited occurrences of *word* in *text*.
|
|
113
|
+
|
|
114
|
+
Uses Unicode-aware boundaries: for CJK words (single or multi-char),
|
|
115
|
+
count raw substring occurrences. For Latin words, uses ``\\b``.
|
|
116
|
+
|
|
117
|
+
Combining marks count as word characters, so a marked word is not matched
|
|
118
|
+
by a prefix of itself that stops just before one of its marks.
|
|
119
|
+
"""
|
|
120
|
+
if any(_is_cjk_char(ch) for ch in word):
|
|
121
|
+
# CJK: count raw substring occurrences (no word boundary concept)
|
|
122
|
+
return text.count(word)
|
|
123
|
+
pattern = rf"(?<!{WORD_CHAR_CLASS}){re.escape(word)}(?!{WORD_CHAR_CLASS})"
|
|
124
|
+
return len(re.findall(pattern, text, re.UNICODE))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SentenceBagRule(ParseTestRule):
|
|
128
|
+
"""Shared utilities for sentence-bag parse rules.
|
|
129
|
+
|
|
130
|
+
⚠️ SYNC: Splitting & boundary logic must stay aligned with the JS annotation tool in
|
|
131
|
+
text_annotation_tools/toBagOfSentences.js (markdownToBagOfSentences).
|
|
132
|
+
If you change orune, update the other.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
MIN_SENTENCE_LENGTH = 7
|
|
136
|
+
_LEADING_MARKDOWN_PATTERN = re.compile(r"^(?:\s*(?:#{1,6}\s+|>\s+|[*+-]\s+|\d+[.)]\s+))+")
|
|
137
|
+
_HTML_TAG_PATTERN = re.compile(r"</?[^>]+>")
|
|
138
|
+
_AUTOLINK_PATTERN = re.compile(r"<((?:https?://|mailto:)[^>\s]+|[^>@\s]+@[^>@\s]+\.[^>@\s]+)>", re.IGNORECASE)
|
|
139
|
+
_MULTI_DOT_PATTERN = re.compile(r"\.(?:\s*\.)+")
|
|
140
|
+
# Split on newlines, non-numeric periods, runs of !/? **and** CJK / Asian
|
|
141
|
+
# sentence separators. Some (。,、!?;) may already have been normalised
|
|
142
|
+
# to ASCII by normalize_text, but we keep the originals for safety.
|
|
143
|
+
# 。 \u3002 、 \u3001 , \uFF0C ! \uFF01 ? \uFF1F ; \uFF1B
|
|
144
|
+
# … \u2026 ‥ \u2025 ⋯ \u22EF
|
|
145
|
+
_SENTENCE_SPLIT_PATTERN = re.compile(
|
|
146
|
+
r"\n+|(?<!\d)\.(?!\d)|[!?]+|[\u3002\u3001\uFF0C\uFF01\uFF1F\uFF1B\u2026\u2025\u22EF]+"
|
|
147
|
+
)
|
|
148
|
+
# Combining marks are word characters here too: without them a trailing Thai
|
|
149
|
+
# tone mark reads as punctuation and gets trimmed off the last word.
|
|
150
|
+
_BOUNDARY_PUNCT_PATTERN = re.compile(rf"^{NON_WORD_CHAR_CLASS}+|{NON_WORD_CHAR_CLASS}+$")
|
|
151
|
+
# Matches markdown image tags:  — stripped entirely so alt text
|
|
152
|
+
# does not produce spurious sentence/word matches.
|
|
153
|
+
_MARKDOWN_IMAGE_PATTERN = re.compile(r"!\[[^\]]*\]\([^)]*\)")
|
|
154
|
+
|
|
155
|
+
def __init__(self, rule_data: SentenceBagRuleData | dict, expected_type: str):
|
|
156
|
+
super().__init__(rule_data)
|
|
157
|
+
rule_data = cast(SentenceBagRuleData, self._rule_data)
|
|
158
|
+
|
|
159
|
+
if self.type != expected_type:
|
|
160
|
+
raise ValueError(f"Invalid type for {self.__class__.__name__}: {self.type}")
|
|
161
|
+
|
|
162
|
+
bag_of_sentence = rule_data.bag_of_sentence
|
|
163
|
+
logger.debug(
|
|
164
|
+
"Initializing %s with bag_of_sentence type=%s size=%s",
|
|
165
|
+
self.__class__.__name__,
|
|
166
|
+
type(bag_of_sentence).__name__,
|
|
167
|
+
len(bag_of_sentence) if isinstance(bag_of_sentence, dict) else None,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if not isinstance(bag_of_sentence, dict) or not bag_of_sentence:
|
|
171
|
+
raise ValueError("bag_of_sentence must be a non-empty dictionary")
|
|
172
|
+
|
|
173
|
+
self.sentence_bag: Counter[str] = Counter()
|
|
174
|
+
for sentence, occurrences in bag_of_sentence.items():
|
|
175
|
+
if not isinstance(sentence, str) or not sentence.strip():
|
|
176
|
+
raise ValueError("bag_of_sentence keys must be non-empty strings")
|
|
177
|
+
if not isinstance(occurrences, int):
|
|
178
|
+
raise ValueError("bag_of_sentence values must be integers")
|
|
179
|
+
if occurrences < 0:
|
|
180
|
+
raise ValueError("bag_of_sentence values cannot be negative")
|
|
181
|
+
|
|
182
|
+
normalized_sentence = self._normalize_sentence_fragment(sentence)
|
|
183
|
+
if not normalized_sentence:
|
|
184
|
+
# Skip noisy fragments (e.g., dot leaders, formatting-only tokens, very short text)
|
|
185
|
+
# so large heterogeneous datasets do not fail rule initialization on one bad entry.
|
|
186
|
+
logger.debug(
|
|
187
|
+
("Skipping bag_of_sentence entry after normalization: original=%r occurrences=%d"),
|
|
188
|
+
sentence,
|
|
189
|
+
occurrences,
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
self.sentence_bag[normalized_sentence] += occurrences
|
|
193
|
+
|
|
194
|
+
if not self.sentence_bag:
|
|
195
|
+
logger.warning(
|
|
196
|
+
("%s has no valid sentence after normalization; executing rule with empty bag_of_sentence"),
|
|
197
|
+
self.__class__.__name__,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _normalize_sentence_fragment(text: str) -> str:
|
|
202
|
+
"""Normalize sentence fragments for stable matching across layouts."""
|
|
203
|
+
normalized_sentence = normalize_text(text).strip().strip(".")
|
|
204
|
+
normalized_sentence = SentenceBagRule._MULTI_DOT_PATTERN.sub(" ", normalized_sentence)
|
|
205
|
+
# Decode entities before stripping tags so encoded markup (<h1>) is treated
|
|
206
|
+
# the same as raw markup (<h1>) and plain symbols (<) normalize to '<'.
|
|
207
|
+
unescaped_sentence = _unescape_html_entities(normalized_sentence)
|
|
208
|
+
if unescaped_sentence != normalized_sentence:
|
|
209
|
+
logger.debug("Decoded HTML entities in sentence fragment during normalization")
|
|
210
|
+
normalized_sentence = unescaped_sentence
|
|
211
|
+
normalized_sentence = SentenceBagRule._AUTOLINK_PATTERN.sub(r"\1", normalized_sentence)
|
|
212
|
+
normalized_sentence = SentenceBagRule._HTML_TAG_PATTERN.sub(" ", normalized_sentence)
|
|
213
|
+
normalized_sentence = SentenceBagRule._LEADING_MARKDOWN_PATTERN.sub("", normalized_sentence).strip()
|
|
214
|
+
normalized_sentence = SentenceBagRule._BOUNDARY_PUNCT_PATTERN.sub("", normalized_sentence).strip()
|
|
215
|
+
normalized_sentence = re.sub(r"\s+", " ", normalized_sentence)
|
|
216
|
+
if len(normalized_sentence) < SentenceBagRule.MIN_SENTENCE_LENGTH:
|
|
217
|
+
return ""
|
|
218
|
+
return normalized_sentence
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _normalize_full_text(md_content: str) -> str:
|
|
222
|
+
"""Normalize full markdown content for substring matching.
|
|
223
|
+
|
|
224
|
+
Applies the same transformations used by ``_normalize_sentence_fragment``
|
|
225
|
+
(normalize_text, HTML entity decoding, HTML tag stripping, multi-dot
|
|
226
|
+
collapsing, whitespace collapse) but on the full document so that
|
|
227
|
+
substring searches are compatible with the per-fragment normalization
|
|
228
|
+
applied to ``bag_of_sentence`` keys during ``__init__``.
|
|
229
|
+
"""
|
|
230
|
+
md_content = _strip_fenced_code_blocks(md_content)
|
|
231
|
+
md_content = _strip_and_replace_latex(md_content)
|
|
232
|
+
md_content = SentenceBagRule._MARKDOWN_IMAGE_PATTERN.sub(" ", md_content)
|
|
233
|
+
md_content = _augment_with_table_cell_text(md_content)
|
|
234
|
+
md_content = normalize_text(md_content)
|
|
235
|
+
md_content = SentenceBagRule._MULTI_DOT_PATTERN.sub(" ", md_content)
|
|
236
|
+
md_content = _unescape_html_entities(md_content)
|
|
237
|
+
md_content = SentenceBagRule._AUTOLINK_PATTERN.sub(r"\1", md_content)
|
|
238
|
+
md_content = SentenceBagRule._HTML_TAG_PATTERN.sub(" ", md_content)
|
|
239
|
+
md_content = re.sub(r"\s+", " ", md_content)
|
|
240
|
+
return md_content
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _count_sentence_in_full_text(sentence: str, full_text: str) -> int:
|
|
244
|
+
"""Count non-overlapping occurrences of *sentence* as a substring of *full_text*.
|
|
245
|
+
|
|
246
|
+
This is the substring-matching counterpart to bag-based lookup. It
|
|
247
|
+
handles cases where sentence boundary splitting produces different
|
|
248
|
+
fragments than the annotated bag keys (e.g. abbreviations, line breaks).
|
|
249
|
+
|
|
250
|
+
Uses word-boundary anchors for short sentences (< 20 chars) to avoid
|
|
251
|
+
false positives where e.g. "the" matches inside "then" or "other".
|
|
252
|
+
Longer sentences are unlikely to produce false substring hits, so
|
|
253
|
+
plain ``str.count`` is used for performance.
|
|
254
|
+
"""
|
|
255
|
+
if len(sentence) < 20:
|
|
256
|
+
pattern = rf"(?<!{WORD_CHAR_CLASS}){re.escape(sentence)}(?!{WORD_CHAR_CLASS})"
|
|
257
|
+
return len(re.findall(pattern, full_text))
|
|
258
|
+
return full_text.count(sentence)
|
|
259
|
+
|
|
260
|
+
def _extract_normalized_sentences(self, md_content: str, include_table_cells: bool = False) -> Counter[str]:
|
|
261
|
+
"""Split by sentence boundaries and return normalized occurrence counts."""
|
|
262
|
+
return SentenceBagRule._extract_normalized_sentences_static(md_content, include_table_cells=include_table_cells)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _merge_short_chunks(chunks: list[str]) -> list[str]:
|
|
266
|
+
"""Merge chunks shorter than MIN_SENTENCE_LENGTH with the next chunk.
|
|
267
|
+
|
|
268
|
+
This prevents short but valid sentence fragments (e.g. "Fig 1",
|
|
269
|
+
"See A") from being silently dropped during sentence extraction.
|
|
270
|
+
"""
|
|
271
|
+
merged: list[str] = []
|
|
272
|
+
carry = ""
|
|
273
|
+
for chunk in chunks:
|
|
274
|
+
text = chunk.strip()
|
|
275
|
+
if not text:
|
|
276
|
+
continue
|
|
277
|
+
if carry:
|
|
278
|
+
text = carry + " " + text
|
|
279
|
+
carry = ""
|
|
280
|
+
# Check normalized length to decide if merge is needed
|
|
281
|
+
normalized = SentenceBagRule._normalize_sentence_fragment(text)
|
|
282
|
+
if not normalized and len(text.strip()) > 0:
|
|
283
|
+
# Too short after normalization — carry forward to merge with next
|
|
284
|
+
carry = text
|
|
285
|
+
else:
|
|
286
|
+
merged.append(text)
|
|
287
|
+
# If there's a leftover carry, append to last entry or add standalone
|
|
288
|
+
if carry:
|
|
289
|
+
if merged:
|
|
290
|
+
merged[-1] = merged[-1] + " " + carry
|
|
291
|
+
else:
|
|
292
|
+
merged.append(carry)
|
|
293
|
+
return merged
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _extract_normalized_sentences_static(md_content: str, include_table_cells: bool = False) -> Counter[str]:
|
|
297
|
+
"""Split by sentence boundaries and return normalized occurrence counts.
|
|
298
|
+
|
|
299
|
+
Fenced code blocks (mermaid, description) and LaTeX are stripped before
|
|
300
|
+
sentence extraction. Short chunks (< MIN_SENTENCE_LENGTH after
|
|
301
|
+
normalization) are merged with the next chunk instead of being dropped.
|
|
302
|
+
"""
|
|
303
|
+
md_content = _strip_fenced_code_blocks(md_content)
|
|
304
|
+
md_content = _strip_and_replace_latex(md_content)
|
|
305
|
+
md_content = SentenceBagRule._MARKDOWN_IMAGE_PATTERN.sub(" ", md_content)
|
|
306
|
+
if include_table_cells:
|
|
307
|
+
md_content = _augment_with_table_cell_text(md_content)
|
|
308
|
+
else:
|
|
309
|
+
md_content = _strip_html_tables_and_content(md_content)
|
|
310
|
+
md_content = SentenceBagRule._MULTI_DOT_PATTERN.sub(" ", md_content)
|
|
311
|
+
sentence_chunks = SentenceBagRule._SENTENCE_SPLIT_PATTERN.split(md_content)
|
|
312
|
+
# Merge short chunks with the next one to avoid losing short fragments
|
|
313
|
+
sentence_chunks = SentenceBagRule._merge_short_chunks(sentence_chunks)
|
|
314
|
+
sentence_counter: Counter[str] = Counter()
|
|
315
|
+
for chunk in sentence_chunks:
|
|
316
|
+
normalized_sentence = SentenceBagRule._normalize_sentence_fragment(chunk)
|
|
317
|
+
if normalized_sentence:
|
|
318
|
+
sentence_counter[normalized_sentence] += 1
|
|
319
|
+
return sentence_counter
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _format_sentence_debug(sentence: str, edge_chars: int = 48) -> str:
|
|
323
|
+
"""Return a compact but unique sentence descriptor for failure messages.
|
|
324
|
+
|
|
325
|
+
Why: sentence previews that only show the first characters can hide differences
|
|
326
|
+
in long strings (e.g., trailing tokens or punctuation), leading to confusing
|
|
327
|
+
diagnostics where a sentence appears both missing and unexpected.
|
|
328
|
+
"""
|
|
329
|
+
sentence_hash = hashlib.sha1(sentence.encode("utf-8")).hexdigest()[:10]
|
|
330
|
+
if len(sentence) <= edge_chars * 2:
|
|
331
|
+
compact = sentence
|
|
332
|
+
else:
|
|
333
|
+
compact = f"{sentence[:edge_chars]}…{sentence[-edge_chars:]}"
|
|
334
|
+
return f"{compact!r} [len={len(sentence)}, sha1={sentence_hash}]"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class UnexpectedSentenceRule(SentenceBagRule):
|
|
338
|
+
"""Fail when output contains sentence fragments not listed in bag_of_sentence.
|
|
339
|
+
|
|
340
|
+
An actual sentence is considered expected if it appears in the bag
|
|
341
|
+
(exact match) OR if it is a substring of any bag entry (handles
|
|
342
|
+
sentence-boundary misalignment where the actual fragment is a
|
|
343
|
+
sub-piece of an expected sentence).
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
def __init__(self, rule_data: ParseUnexpectedSentenceRule):
|
|
347
|
+
super().__init__(rule_data, TestType.UNEXPECTED_SENTENCE.value)
|
|
348
|
+
# Pre-build a concatenated reference text from bag keys for substring fallback
|
|
349
|
+
self._bag_full_text: str = " ".join(self.sentence_bag.keys())
|
|
350
|
+
|
|
351
|
+
def _is_expected(self, sentence: str) -> bool:
|
|
352
|
+
"""Return True if *sentence* is in the bag or is a substring of any bag entry."""
|
|
353
|
+
if sentence in self.sentence_bag:
|
|
354
|
+
return True
|
|
355
|
+
if sentence in self._bag_full_text:
|
|
356
|
+
return True
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str]:
|
|
360
|
+
actual_sentence_bag = self._extract_normalized_sentences(md_content)
|
|
361
|
+
|
|
362
|
+
unexpected = [
|
|
363
|
+
(sentence, actual_count)
|
|
364
|
+
for sentence, actual_count in actual_sentence_bag.items()
|
|
365
|
+
if not self._is_expected(sentence)
|
|
366
|
+
]
|
|
367
|
+
if not unexpected:
|
|
368
|
+
return True, ""
|
|
369
|
+
|
|
370
|
+
unexpected.sort(key=lambda item: (-item[1], item[0]))
|
|
371
|
+
preview = "; ".join(f"{self._format_sentence_debug(sentence)} ({count}x)" for sentence, count in unexpected)
|
|
372
|
+
return False, f"Found unexpected sentence(s): {preview}"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class UnexpectedSentencePercentRule(SentenceBagRule):
|
|
376
|
+
"""Score unexpected-sentence compliance in [0, 1].
|
|
377
|
+
|
|
378
|
+
1.0 means all observed sentence fragments are in `bag_of_sentence`.
|
|
379
|
+
0.0 means no observed sentence fragment is in `bag_of_sentence`.
|
|
380
|
+
|
|
381
|
+
When ``original_md`` is provided in the rule data, a sentence that is
|
|
382
|
+
not in ``bag_of_sentence`` is still considered expected if it appears
|
|
383
|
+
as a substring of the normalized original markdown. This avoids false
|
|
384
|
+
positives caused by sentence-boundary misalignment or line breaks.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
def __init__(self, rule_data: ParseUnexpectedSentencePercentRule):
|
|
388
|
+
super().__init__(rule_data, TestType.UNEXPECTED_SENTENCE_PERCENT.value)
|
|
389
|
+
rule_data = cast(ParseUnexpectedSentencePercentRule, self._rule_data)
|
|
390
|
+
original_md = rule_data.original_md
|
|
391
|
+
if original_md is not None:
|
|
392
|
+
self._normalized_original_md: str | None = SentenceBagRule._normalize_full_text(original_md)
|
|
393
|
+
else:
|
|
394
|
+
self._normalized_original_md = None
|
|
395
|
+
# Pre-build a concatenated reference text from bag keys for substring fallback
|
|
396
|
+
self._bag_full_text: str = " ".join(self.sentence_bag.keys())
|
|
397
|
+
|
|
398
|
+
def _is_expected(self, sentence: str) -> bool:
|
|
399
|
+
"""Return True if *sentence* is in the bag, is a substring of a bag entry, or found in the original MD."""
|
|
400
|
+
if sentence in self.sentence_bag:
|
|
401
|
+
return True
|
|
402
|
+
if sentence in self._bag_full_text:
|
|
403
|
+
return True
|
|
404
|
+
if self._normalized_original_md is not None and sentence in self._normalized_original_md:
|
|
405
|
+
return True
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
|
|
409
|
+
actual_sentence_bag = self._extract_normalized_sentences(md_content)
|
|
410
|
+
|
|
411
|
+
total_actual = sum(actual_sentence_bag.values())
|
|
412
|
+
if total_actual == 0:
|
|
413
|
+
return True, "", 1.0
|
|
414
|
+
|
|
415
|
+
expected_hits = sum(count for sentence, count in actual_sentence_bag.items() if self._is_expected(sentence))
|
|
416
|
+
score = max(0.0, min(1.0, expected_hits / total_actual))
|
|
417
|
+
|
|
418
|
+
unexpected = [
|
|
419
|
+
(sentence, actual_count)
|
|
420
|
+
for sentence, actual_count in actual_sentence_bag.items()
|
|
421
|
+
if not self._is_expected(sentence)
|
|
422
|
+
]
|
|
423
|
+
if not unexpected:
|
|
424
|
+
return True, "", score
|
|
425
|
+
|
|
426
|
+
unexpected.sort(key=lambda item: (-item[1], item[0]))
|
|
427
|
+
preview = "; ".join(f"{self._format_sentence_debug(sentence)} ({count}x)" for sentence, count in unexpected[:5])
|
|
428
|
+
return (
|
|
429
|
+
False,
|
|
430
|
+
f"Unexpected sentence percent score={score:.3f}; unexpected: {preview}",
|
|
431
|
+
score,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class TooManySentenceOccurenceRule(SentenceBagRule):
|
|
436
|
+
"""Fail when a configured sentence appears more times than allowed.
|
|
437
|
+
|
|
438
|
+
Uses the maximum of bag-based and substring-based counts so that
|
|
439
|
+
sentence-boundary misalignment does not hide genuine duplications.
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
def __init__(self, rule_data: ParseTooManySentenceOccurrenceRule):
|
|
443
|
+
super().__init__(rule_data, TestType.TOO_MANY_SENTENCE_OCCURENCE.value)
|
|
444
|
+
|
|
445
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str]:
|
|
446
|
+
actual_sentence_bag = self._extract_normalized_sentences(md_content)
|
|
447
|
+
full_text = self._normalize_full_text(md_content)
|
|
448
|
+
|
|
449
|
+
too_many: list[tuple[str, int, int, int]] = []
|
|
450
|
+
for sentence, allowed_count in self.sentence_bag.items():
|
|
451
|
+
bag_count = actual_sentence_bag.get(sentence, 0)
|
|
452
|
+
substr_count = self._count_sentence_in_full_text(sentence, full_text)
|
|
453
|
+
actual_count = max(bag_count, substr_count)
|
|
454
|
+
if actual_count > allowed_count:
|
|
455
|
+
too_many.append((sentence, actual_count - allowed_count, actual_count, allowed_count))
|
|
456
|
+
|
|
457
|
+
if not too_many:
|
|
458
|
+
return True, ""
|
|
459
|
+
|
|
460
|
+
too_many.sort(key=lambda item: (-item[1], item[0]))
|
|
461
|
+
preview = "; ".join(
|
|
462
|
+
f"{self._format_sentence_debug(sentence)} ({actual}>{allowed})" for sentence, _, actual, allowed in too_many
|
|
463
|
+
)
|
|
464
|
+
return False, f"Found too many sentence occurrence(s): {preview}"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class TooManySentenceOccurencePercentRule(SentenceBagRule):
|
|
468
|
+
"""Score over-limit sentence compliance in [0, 1].
|
|
469
|
+
|
|
470
|
+
1.0 means no configured sentence exceeds its allowed count.
|
|
471
|
+
0.0 means fully over-limit behavior for configured sentences.
|
|
472
|
+
|
|
473
|
+
Uses the maximum of bag-based and substring-based counts for consistency.
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
def __init__(self, rule_data: ParseTooManySentenceOccurrencePercentRule):
|
|
477
|
+
super().__init__(rule_data, TestType.TOO_MANY_SENTENCE_OCCURENCE_PERCENT.value)
|
|
478
|
+
|
|
479
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
|
|
480
|
+
actual_sentence_bag = self._extract_normalized_sentences(md_content)
|
|
481
|
+
full_text = self._normalize_full_text(md_content)
|
|
482
|
+
|
|
483
|
+
total_excess = 0
|
|
484
|
+
total_denominator = 0
|
|
485
|
+
too_many: list[tuple[str, int, int, int]] = []
|
|
486
|
+
|
|
487
|
+
for sentence, allowed_count in self.sentence_bag.items():
|
|
488
|
+
bag_count = actual_sentence_bag.get(sentence, 0)
|
|
489
|
+
substr_count = self._count_sentence_in_full_text(sentence, full_text)
|
|
490
|
+
actual_count = max(bag_count, substr_count)
|
|
491
|
+
excess = max(0, actual_count - allowed_count)
|
|
492
|
+
total_excess += excess
|
|
493
|
+
total_denominator += max(actual_count, allowed_count)
|
|
494
|
+
if excess > 0:
|
|
495
|
+
too_many.append((sentence, excess, actual_count, allowed_count))
|
|
496
|
+
|
|
497
|
+
if total_denominator == 0:
|
|
498
|
+
score = 1.0
|
|
499
|
+
else:
|
|
500
|
+
score = max(0.0, min(1.0, 1.0 - (total_excess / total_denominator)))
|
|
501
|
+
|
|
502
|
+
if not too_many:
|
|
503
|
+
return True, "", score
|
|
504
|
+
|
|
505
|
+
too_many.sort(key=lambda item: (-item[1], item[0]))
|
|
506
|
+
preview = "; ".join(
|
|
507
|
+
f"{self._format_sentence_debug(sentence)} ({actual}>{allowed})"
|
|
508
|
+
for sentence, _, actual, allowed in too_many[:5]
|
|
509
|
+
)
|
|
510
|
+
return (
|
|
511
|
+
False,
|
|
512
|
+
f"Too-many sentence percent score={score:.3f}; over-limit: {preview}",
|
|
513
|
+
score,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class MissingSentenceRule(SentenceBagRule):
|
|
518
|
+
"""Fail when a configured sentence appears fewer times than required.
|
|
519
|
+
|
|
520
|
+
Uses substring matching in normalized full text (consistent with
|
|
521
|
+
``MissingSentencePercentRule``) to avoid false negatives from
|
|
522
|
+
sentence-boundary misalignment. Falls back to bag-based counting
|
|
523
|
+
only when substring matching finds fewer occurrences.
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
def __init__(self, rule_data: ParseMissingSentenceRule):
|
|
527
|
+
super().__init__(rule_data, TestType.MISSING_SENTENCE.value)
|
|
528
|
+
|
|
529
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str]:
|
|
530
|
+
actual_sentence_bag = self._extract_normalized_sentences(
|
|
531
|
+
md_content,
|
|
532
|
+
include_table_cells=True,
|
|
533
|
+
)
|
|
534
|
+
full_text = self._normalize_full_text(md_content)
|
|
535
|
+
|
|
536
|
+
missing: list[tuple[str, int, int, int]] = []
|
|
537
|
+
for sentence, required_count in self.sentence_bag.items():
|
|
538
|
+
bag_count = actual_sentence_bag.get(sentence, 0)
|
|
539
|
+
substr_count = self._count_sentence_in_full_text(sentence, full_text)
|
|
540
|
+
actual_count = max(bag_count, substr_count)
|
|
541
|
+
if actual_count < required_count:
|
|
542
|
+
missing.append((sentence, required_count - actual_count, actual_count, required_count))
|
|
543
|
+
|
|
544
|
+
if not missing:
|
|
545
|
+
return True, ""
|
|
546
|
+
|
|
547
|
+
missing.sort(key=lambda item: (-item[1], item[0]))
|
|
548
|
+
preview = "; ".join(
|
|
549
|
+
f"{self._format_sentence_debug(sentence)} ({actual}<{required})"
|
|
550
|
+
for sentence, _, actual, required in missing
|
|
551
|
+
)
|
|
552
|
+
return False, f"Missing sentence occurrence(s): {preview}"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class MissingSentencePercentRule(SentenceBagRule):
|
|
556
|
+
"""Score required-sentence coverage in [0, 1].
|
|
557
|
+
|
|
558
|
+
1.0 means all required sentence occurrences are present.
|
|
559
|
+
0.0 means none of the required sentence occurrences are present.
|
|
560
|
+
|
|
561
|
+
Checks whether each required sentence appears as a substring in the
|
|
562
|
+
normalized full markdown text (rather than splitting into a bag of
|
|
563
|
+
discrete sentences). This avoids false negatives caused by sentence-
|
|
564
|
+
boundary misalignment.
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
def __init__(self, rule_data: ParseMissingSentencePercentRule):
|
|
568
|
+
super().__init__(rule_data, TestType.MISSING_SENTENCE_PERCENT.value)
|
|
569
|
+
|
|
570
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
|
|
571
|
+
full_text = self._normalize_full_text(md_content)
|
|
572
|
+
|
|
573
|
+
total_required = sum(self.sentence_bag.values())
|
|
574
|
+
matched_required = 0
|
|
575
|
+
missing: list[tuple[str, int, int, int]] = []
|
|
576
|
+
|
|
577
|
+
for sentence, required_count in self.sentence_bag.items():
|
|
578
|
+
actual_count = self._count_sentence_in_full_text(sentence, full_text)
|
|
579
|
+
matched_required += min(actual_count, required_count)
|
|
580
|
+
if actual_count < required_count:
|
|
581
|
+
missing.append((sentence, required_count - actual_count, actual_count, required_count))
|
|
582
|
+
|
|
583
|
+
if total_required == 0:
|
|
584
|
+
score = 1.0
|
|
585
|
+
else:
|
|
586
|
+
score = max(0.0, min(1.0, matched_required / total_required))
|
|
587
|
+
|
|
588
|
+
if not missing:
|
|
589
|
+
return True, "", score
|
|
590
|
+
|
|
591
|
+
missing.sort(key=lambda item: (-item[1], item[0]))
|
|
592
|
+
preview = "; ".join(
|
|
593
|
+
f"{self._format_sentence_debug(sentence)} ({actual}<{required})"
|
|
594
|
+
for sentence, _, actual, required in missing[:5]
|
|
595
|
+
)
|
|
596
|
+
return (
|
|
597
|
+
False,
|
|
598
|
+
f"Missing sentence percent score={score:.3f}; missing: {preview}",
|
|
599
|
+
score,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class MissingSpecificSentenceRule(ParseTestRule):
|
|
604
|
+
"""Fail when a specific sentence is not found in the content.
|
|
605
|
+
|
|
606
|
+
Unlike `MissingSentenceRule`, this rule targets a single sentence rather
|
|
607
|
+
than a bag of sentences, making it simpler to author for one-off checks.
|
|
608
|
+
Checks whether the normalized sentence appears as a substring in the
|
|
609
|
+
normalized full markdown text, so sentences split by line breaks are
|
|
610
|
+
still matched.
|
|
611
|
+
"""
|
|
612
|
+
|
|
613
|
+
def __init__(self, rule_data: ParseMissingSpecificSentenceRule):
|
|
614
|
+
super().__init__(rule_data)
|
|
615
|
+
if self.type != TestType.MISSING_SPECIFIC_SENTENCE.value:
|
|
616
|
+
raise ValueError(f"Invalid type for MissingSpecificSentenceRule: {self.type}")
|
|
617
|
+
rule_data = cast(ParseMissingSpecificSentenceRule, self._rule_data)
|
|
618
|
+
raw_sentence = rule_data.sentence
|
|
619
|
+
if not isinstance(raw_sentence, str) or not raw_sentence.strip():
|
|
620
|
+
raise ValueError("sentence must be a non-empty string")
|
|
621
|
+
self.normalized_sentence = SentenceBagRule._normalize_sentence_fragment(raw_sentence)
|
|
622
|
+
if not self.normalized_sentence:
|
|
623
|
+
raise ValueError(f"sentence is too short or empty after normalization: {raw_sentence!r}")
|
|
624
|
+
|
|
625
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str]:
|
|
626
|
+
full_text = SentenceBagRule._normalize_full_text(md_content)
|
|
627
|
+
if self.normalized_sentence in full_text:
|
|
628
|
+
return True, ""
|
|
629
|
+
preview = SentenceBagRule._format_sentence_debug(self.normalized_sentence)
|
|
630
|
+
return False, f"Missing specific sentence: {preview}"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
class WordBagRule(ParseTestRule):
|
|
634
|
+
"""Shared utilities for word-bag parse rules."""
|
|
635
|
+
|
|
636
|
+
MIN_WORD_LENGTH = 2
|
|
637
|
+
_LEADING_MARKDOWN_PATTERN = re.compile(r"^(?:\s*(?:#{1,6}\s+|>\s+|[*+-]\s+|\d+[.)]\s+))+")
|
|
638
|
+
_HTML_TAG_PATTERN = re.compile(r"</?[^>]+>")
|
|
639
|
+
|
|
640
|
+
def __init__(self, rule_data: WordBagRuleData | dict, expected_type: str):
|
|
641
|
+
super().__init__(rule_data)
|
|
642
|
+
rule_data = cast(WordBagRuleData, self._rule_data)
|
|
643
|
+
|
|
644
|
+
if self.type != expected_type:
|
|
645
|
+
raise ValueError(f"Invalid type for {self.__class__.__name__}: {self.type}")
|
|
646
|
+
|
|
647
|
+
bag_of_word = rule_data.bag_of_word
|
|
648
|
+
if not isinstance(bag_of_word, dict) or not bag_of_word:
|
|
649
|
+
raise ValueError("bag_of_word must be a non-empty dictionary")
|
|
650
|
+
|
|
651
|
+
self.word_bag: Counter[str] = Counter()
|
|
652
|
+
for word, occurrences in bag_of_word.items():
|
|
653
|
+
if not isinstance(word, str) or not word.strip():
|
|
654
|
+
raise ValueError("bag_of_word keys must be non-empty strings")
|
|
655
|
+
if not isinstance(occurrences, int):
|
|
656
|
+
raise ValueError("bag_of_word values must be integers")
|
|
657
|
+
if occurrences < 0:
|
|
658
|
+
raise ValueError("bag_of_word values cannot be negative")
|
|
659
|
+
|
|
660
|
+
normalized_word = self._normalize_word_fragment(word)
|
|
661
|
+
if not normalized_word:
|
|
662
|
+
continue
|
|
663
|
+
self.word_bag[normalized_word] += occurrences
|
|
664
|
+
|
|
665
|
+
if not self.word_bag:
|
|
666
|
+
raise ValueError(
|
|
667
|
+
f"bag_of_word has no valid word after normalization "
|
|
668
|
+
f"(words must be >= {WordBagRule.MIN_WORD_LENGTH} characters)"
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
@staticmethod
|
|
672
|
+
def _normalize_word_fragment(text: str) -> str:
|
|
673
|
+
"""Normalize a word token for robust matching.
|
|
674
|
+
|
|
675
|
+
Uses Unicode-aware tokenization so accented characters and CJK
|
|
676
|
+
characters are handled correctly.
|
|
677
|
+
"""
|
|
678
|
+
normalized = normalize_text(text)
|
|
679
|
+
unescaped_word = _unescape_html_entities(normalized)
|
|
680
|
+
if unescaped_word != normalized:
|
|
681
|
+
logger.debug("Decoded HTML entities in bag_of_word entry during normalization")
|
|
682
|
+
normalized = unescaped_word
|
|
683
|
+
normalized = SentenceBagRule._AUTOLINK_PATTERN.sub(r"\1", normalized)
|
|
684
|
+
normalized = WordBagRule._HTML_TAG_PATTERN.sub(" ", normalized)
|
|
685
|
+
normalized = WordBagRule._LEADING_MARKDOWN_PATTERN.sub("", normalized).strip()
|
|
686
|
+
words = _tokenize_unicode_words(normalized, min_length=WordBagRule.MIN_WORD_LENGTH)
|
|
687
|
+
if not words:
|
|
688
|
+
return ""
|
|
689
|
+
return words[0]
|
|
690
|
+
|
|
691
|
+
@staticmethod
|
|
692
|
+
def _normalize_full_word_text(md_content: str) -> str:
|
|
693
|
+
"""Normalize full markdown content for word-level substring matching.
|
|
694
|
+
|
|
695
|
+
Applies the same pipeline as ``_extract_normalized_words_static`` but
|
|
696
|
+
returns the full normalized text instead of tokenizing it, so that
|
|
697
|
+
word-boundary regex searches can be used as a fallback.
|
|
698
|
+
"""
|
|
699
|
+
md_content = _strip_fenced_code_blocks(md_content)
|
|
700
|
+
md_content = _strip_and_replace_latex(md_content)
|
|
701
|
+
md_content = SentenceBagRule._MARKDOWN_IMAGE_PATTERN.sub(" ", md_content)
|
|
702
|
+
md_content = _augment_with_table_cell_text(md_content)
|
|
703
|
+
normalized_content = normalize_text(md_content)
|
|
704
|
+
unescaped_content = _unescape_html_entities(normalized_content)
|
|
705
|
+
normalized_content = unescaped_content
|
|
706
|
+
normalized_content = SentenceBagRule._AUTOLINK_PATTERN.sub(r"\1", normalized_content)
|
|
707
|
+
normalized_content = WordBagRule._HTML_TAG_PATTERN.sub(" ", normalized_content)
|
|
708
|
+
return normalized_content
|
|
709
|
+
|
|
710
|
+
@staticmethod
|
|
711
|
+
def _count_word_in_full_text(word: str, full_text: str) -> int:
|
|
712
|
+
"""Count word-boundary-delimited occurrences of *word* in *full_text*.
|
|
713
|
+
|
|
714
|
+
This is a substring fallback for bag-based word lookup. Uses
|
|
715
|
+
Unicode-aware word boundaries so accented and CJK words are
|
|
716
|
+
handled correctly.
|
|
717
|
+
"""
|
|
718
|
+
return _word_boundary_count(word, full_text)
|
|
719
|
+
|
|
720
|
+
def _extract_normalized_words(self, md_content: str, include_table_cells: bool = False) -> Counter[str]:
|
|
721
|
+
"""Tokenize normalized content and return word occurrence counts."""
|
|
722
|
+
return WordBagRule._extract_normalized_words_static(
|
|
723
|
+
md_content,
|
|
724
|
+
include_table_cells=include_table_cells,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
@staticmethod
|
|
728
|
+
def _extract_normalized_words_static(md_content: str, include_table_cells: bool = False) -> Counter[str]:
|
|
729
|
+
"""Tokenize normalized content and return word occurrence counts.
|
|
730
|
+
|
|
731
|
+
Fenced code blocks (mermaid, description) and LaTeX are stripped before
|
|
732
|
+
tokenization.
|
|
733
|
+
"""
|
|
734
|
+
md_content = _strip_fenced_code_blocks(md_content)
|
|
735
|
+
md_content = _strip_and_replace_latex(md_content)
|
|
736
|
+
md_content = SentenceBagRule._MARKDOWN_IMAGE_PATTERN.sub(" ", md_content)
|
|
737
|
+
if include_table_cells:
|
|
738
|
+
md_content = _augment_with_table_cell_text(md_content)
|
|
739
|
+
else:
|
|
740
|
+
md_content = _strip_html_tables_and_content(md_content)
|
|
741
|
+
normalized_content = normalize_text(md_content)
|
|
742
|
+
unescaped_content = _unescape_html_entities(normalized_content)
|
|
743
|
+
if unescaped_content != normalized_content:
|
|
744
|
+
logger.debug("Decoded HTML entities in content before word tokenization")
|
|
745
|
+
normalized_content = unescaped_content
|
|
746
|
+
normalized_content = SentenceBagRule._AUTOLINK_PATTERN.sub(r"\1", normalized_content)
|
|
747
|
+
normalized_content = WordBagRule._HTML_TAG_PATTERN.sub(" ", normalized_content)
|
|
748
|
+
words = _tokenize_unicode_words(normalized_content, min_length=WordBagRule.MIN_WORD_LENGTH)
|
|
749
|
+
return Counter(words)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
class UnexpectedWordRule(WordBagRule):
|
|
753
|
+
"""Fail when output contains words not listed in bag_of_word."""
|
|
754
|
+
|
|
755
|
+
def __init__(self, rule_data: ParseUnexpectedWordRule):
|
|
756
|
+
super().__init__(rule_data, TestType.UNEXPECTED_WORD.value)
|
|
757
|
+
|
|
758
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str]:
|
|
759
|
+
actual_word_bag = self._extract_normalized_words(md_content)
|
|
760
|
+
|
|
761
|
+
unexpected = [
|
|
762
|
+
(word, actual_count) for word, actual_count in actual_word_bag.items() if word not in self.word_bag
|
|
763
|
+
]
|
|
764
|
+
if not unexpected:
|
|
765
|
+
return True, ""
|
|
766
|
+
|
|
767
|
+
unexpected.sort(key=lambda item: (-item[1], item[0]))
|
|
768
|
+
preview = "; ".join(f"'{word}' ({count}x)" for word, count in unexpected)
|
|
769
|
+
return False, f"Found unexpected word(s): {preview}"
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
class UnexpectedWordPercentRule(WordBagRule):
|
|
773
|
+
"""Score unexpected-word compliance in [0, 1].
|
|
774
|
+
|
|
775
|
+
1.0 means all observed words are in `bag_of_word`.
|
|
776
|
+
0.0 means no observed word is in `bag_of_word`.
|
|
777
|
+
"""
|
|
778
|
+
|
|
779
|
+
def __init__(self, rule_data: ParseUnexpectedWordPercentRule):
|
|
780
|
+
super().__init__(rule_data, TestType.UNEXPECTED_WORD_PERCENT.value)
|
|
781
|
+
|
|
782
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
|
|
783
|
+
actual_word_bag = self._extract_normalized_words(md_content)
|
|
784
|
+
|
|
785
|
+
total_actual = sum(actual_word_bag.values())
|
|
786
|
+
if total_actual == 0:
|
|
787
|
+
return True, "", 1.0
|
|
788
|
+
|
|
789
|
+
expected_hits = sum(count for word, count in actual_word_bag.items() if word in self.word_bag)
|
|
790
|
+
score = max(0.0, min(1.0, expected_hits / total_actual))
|
|
791
|
+
|
|
792
|
+
unexpected = [
|
|
793
|
+
(word, actual_count) for word, actual_count in actual_word_bag.items() if word not in self.word_bag
|
|
794
|
+
]
|
|
795
|
+
if not unexpected:
|
|
796
|
+
return True, "", score
|
|
797
|
+
|
|
798
|
+
unexpected.sort(key=lambda item: (-item[1], item[0]))
|
|
799
|
+
preview = "; ".join(f"'{word}' ({count}x)" for word, count in unexpected[:5])
|
|
800
|
+
return (
|
|
801
|
+
False,
|
|
802
|
+
f"Unexpected word percent score={score:.3f}; unexpected: {preview}",
|
|
803
|
+
score,
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
class TooManyWordOccurenceRule(WordBagRule):
|
|
808
|
+
"""Fail when a configured word appears more times than allowed.
|
|
809
|
+
|
|
810
|
+
Uses the maximum of tokenized-bag and word-boundary substring counts
|
|
811
|
+
for consistency with missing-word rules.
|
|
812
|
+
"""
|
|
813
|
+
|
|
814
|
+
def __init__(self, rule_data: ParseTooManyWordOccurrenceRule):
|
|
815
|
+
super().__init__(rule_data, TestType.TOO_MANY_WORD_OCCURENCE.value)
|
|
816
|
+
|
|
817
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str]:
|
|
818
|
+
actual_word_bag = self._extract_normalized_words(md_content)
|
|
819
|
+
full_text = self._normalize_full_word_text(md_content)
|
|
820
|
+
|
|
821
|
+
too_many: list[tuple[str, int, int, int]] = []
|
|
822
|
+
for word, allowed_count in self.word_bag.items():
|
|
823
|
+
bag_count = actual_word_bag.get(word, 0)
|
|
824
|
+
substr_count = self._count_word_in_full_text(word, full_text)
|
|
825
|
+
actual_count = max(bag_count, substr_count)
|
|
826
|
+
if actual_count > allowed_count:
|
|
827
|
+
too_many.append((word, actual_count - allowed_count, actual_count, allowed_count))
|
|
828
|
+
|
|
829
|
+
if not too_many:
|
|
830
|
+
return True, ""
|
|
831
|
+
|
|
832
|
+
too_many.sort(key=lambda item: (-item[1], item[0]))
|
|
833
|
+
preview = "; ".join(f"'{word}' ({actual}>{allowed})" for word, _, actual, allowed in too_many)
|
|
834
|
+
return False, f"Found too many word occurrence(s): {preview}"
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
class TooManyWordOccurencePercentRule(WordBagRule):
|
|
838
|
+
"""Score over-limit compliance in [0, 1].
|
|
839
|
+
|
|
840
|
+
1.0 means no configured word exceeds its allowed count.
|
|
841
|
+
0.0 means every counted token among configured words is over-limit.
|
|
842
|
+
|
|
843
|
+
Uses the maximum of tokenized-bag and word-boundary substring counts
|
|
844
|
+
for consistency with missing-word rules.
|
|
845
|
+
"""
|
|
846
|
+
|
|
847
|
+
def __init__(self, rule_data: ParseTooManyWordOccurrencePercentRule):
|
|
848
|
+
super().__init__(rule_data, TestType.TOO_MANY_WORD_OCCURENCE_PERCENT.value)
|
|
849
|
+
|
|
850
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
|
|
851
|
+
actual_word_bag = self._extract_normalized_words(md_content)
|
|
852
|
+
full_text = self._normalize_full_word_text(md_content)
|
|
853
|
+
|
|
854
|
+
total_excess = 0
|
|
855
|
+
total_denominator = 0
|
|
856
|
+
too_many: list[tuple[str, int, int, int]] = []
|
|
857
|
+
|
|
858
|
+
for word, allowed_count in self.word_bag.items():
|
|
859
|
+
bag_count = actual_word_bag.get(word, 0)
|
|
860
|
+
substr_count = self._count_word_in_full_text(word, full_text)
|
|
861
|
+
actual_count = max(bag_count, substr_count)
|
|
862
|
+
excess = max(0, actual_count - allowed_count)
|
|
863
|
+
total_excess += excess
|
|
864
|
+
total_denominator += max(actual_count, allowed_count)
|
|
865
|
+
if excess > 0:
|
|
866
|
+
too_many.append((word, excess, actual_count, allowed_count))
|
|
867
|
+
|
|
868
|
+
if total_denominator == 0:
|
|
869
|
+
score = 1.0
|
|
870
|
+
else:
|
|
871
|
+
score = max(0.0, min(1.0, 1.0 - (total_excess / total_denominator)))
|
|
872
|
+
|
|
873
|
+
if not too_many:
|
|
874
|
+
return True, "", score
|
|
875
|
+
|
|
876
|
+
too_many.sort(key=lambda item: (-item[1], item[0]))
|
|
877
|
+
preview = "; ".join(f"'{word}' ({actual}>{allowed})" for word, _, actual, allowed in too_many[:5])
|
|
878
|
+
return (
|
|
879
|
+
False,
|
|
880
|
+
f"Too-many word percent score={score:.3f}; over-limit: {preview}",
|
|
881
|
+
score,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
class MissingWordRule(WordBagRule):
|
|
886
|
+
"""Fail when a configured word appears fewer times than required.
|
|
887
|
+
|
|
888
|
+
Uses the maximum of tokenized-bag and word-boundary substring counts
|
|
889
|
+
so that tokenization artifacts do not cause false negatives.
|
|
890
|
+
"""
|
|
891
|
+
|
|
892
|
+
def __init__(self, rule_data: ParseMissingWordRule):
|
|
893
|
+
super().__init__(rule_data, TestType.MISSING_WORD.value)
|
|
894
|
+
|
|
895
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str]:
|
|
896
|
+
actual_word_bag = self._extract_normalized_words(md_content, include_table_cells=True)
|
|
897
|
+
full_text = self._normalize_full_word_text(md_content)
|
|
898
|
+
|
|
899
|
+
missing: list[tuple[str, int, int, int]] = []
|
|
900
|
+
for word, required_count in self.word_bag.items():
|
|
901
|
+
bag_count = actual_word_bag.get(word, 0)
|
|
902
|
+
substr_count = self._count_word_in_full_text(word, full_text)
|
|
903
|
+
actual_count = max(bag_count, substr_count)
|
|
904
|
+
if actual_count < required_count:
|
|
905
|
+
missing.append((word, required_count - actual_count, actual_count, required_count))
|
|
906
|
+
|
|
907
|
+
if not missing:
|
|
908
|
+
return True, ""
|
|
909
|
+
|
|
910
|
+
missing.sort(key=lambda item: (-item[1], item[0]))
|
|
911
|
+
preview = "; ".join(f"'{word}' ({actual}<{required})" for word, _, actual, required in missing)
|
|
912
|
+
return False, f"Missing word occurrence(s): {preview}"
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
class MissingWordPercentRule(WordBagRule):
|
|
916
|
+
"""Score required-word coverage in [0, 1].
|
|
917
|
+
|
|
918
|
+
1.0 means all required occurrences are present.
|
|
919
|
+
0.0 means none of the required occurrences are present.
|
|
920
|
+
|
|
921
|
+
Uses the maximum of tokenized-bag and word-boundary substring counts
|
|
922
|
+
so that tokenization artifacts do not cause false negatives.
|
|
923
|
+
"""
|
|
924
|
+
|
|
925
|
+
def __init__(self, rule_data: ParseMissingWordPercentRule):
|
|
926
|
+
super().__init__(rule_data, TestType.MISSING_WORD_PERCENT.value)
|
|
927
|
+
|
|
928
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
|
|
929
|
+
actual_word_bag = self._extract_normalized_words(md_content, include_table_cells=True)
|
|
930
|
+
full_text = self._normalize_full_word_text(md_content)
|
|
931
|
+
|
|
932
|
+
total_required = sum(self.word_bag.values())
|
|
933
|
+
matched_required = 0
|
|
934
|
+
missing: list[tuple[str, int, int, int]] = []
|
|
935
|
+
|
|
936
|
+
for word, required_count in self.word_bag.items():
|
|
937
|
+
bag_count = actual_word_bag.get(word, 0)
|
|
938
|
+
substr_count = self._count_word_in_full_text(word, full_text)
|
|
939
|
+
actual_count = max(bag_count, substr_count)
|
|
940
|
+
matched_required += min(actual_count, required_count)
|
|
941
|
+
if actual_count < required_count:
|
|
942
|
+
missing.append((word, required_count - actual_count, actual_count, required_count))
|
|
943
|
+
|
|
944
|
+
if total_required == 0:
|
|
945
|
+
score = 1.0
|
|
946
|
+
else:
|
|
947
|
+
score = max(0.0, min(1.0, matched_required / total_required))
|
|
948
|
+
|
|
949
|
+
if not missing:
|
|
950
|
+
return True, "", score
|
|
951
|
+
|
|
952
|
+
missing.sort(key=lambda item: (-item[1], item[0]))
|
|
953
|
+
preview = "; ".join(f"'{word}' ({actual}<{required})" for word, _, actual, required in missing[:5])
|
|
954
|
+
return (
|
|
955
|
+
False,
|
|
956
|
+
f"Missing word percent score={score:.3f}; missing: {preview}",
|
|
957
|
+
score,
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
class MissingSpecificWordRule(ParseTestRule):
|
|
962
|
+
"""Fail when a specific word is not found in the content.
|
|
963
|
+
|
|
964
|
+
Unlike `MissingWordRule`, this rule targets a single word rather than a
|
|
965
|
+
bag of words, making it simpler to author for one-off checks.
|
|
966
|
+
It reuses the same normalization and tokenization logic as word-bag rules.
|
|
967
|
+
"""
|
|
968
|
+
|
|
969
|
+
_APOSTROPHE_PATTERN = re.compile(r"['\u2019]")
|
|
970
|
+
|
|
971
|
+
@classmethod
|
|
972
|
+
def strip_apostrophes(cls, content: str) -> str:
|
|
973
|
+
"""Normalize apostrophe variants used by missing_specific_word fallback matching."""
|
|
974
|
+
return cls._APOSTROPHE_PATTERN.sub("", content)
|
|
975
|
+
|
|
976
|
+
def __init__(self, rule_data: ParseMissingSpecificWordRule):
|
|
977
|
+
super().__init__(rule_data)
|
|
978
|
+
if self.type != TestType.MISSING_SPECIFIC_WORD.value:
|
|
979
|
+
raise ValueError(f"Invalid type for MissingSpecificWordRule: {self.type}")
|
|
980
|
+
rule_data = cast(ParseMissingSpecificWordRule, self._rule_data)
|
|
981
|
+
raw_word = rule_data.word
|
|
982
|
+
if not isinstance(raw_word, str) or not raw_word.strip():
|
|
983
|
+
raise ValueError("word must be a non-empty string")
|
|
984
|
+
# Use a lenient normalization: pick the longest valid token from the
|
|
985
|
+
# fragments (e.g. "d'équipage" → ['d', 'equipage'] → 'equipage').
|
|
986
|
+
# Annotated words should never be rejected outright.
|
|
987
|
+
normalized = normalize_text(raw_word)
|
|
988
|
+
unescaped = _unescape_html_entities(normalized)
|
|
989
|
+
cleaned = SentenceBagRule._AUTOLINK_PATTERN.sub(r"\1", unescaped)
|
|
990
|
+
cleaned = WordBagRule._HTML_TAG_PATTERN.sub(" ", cleaned)
|
|
991
|
+
cleaned = WordBagRule._LEADING_MARKDOWN_PATTERN.sub("", cleaned).strip()
|
|
992
|
+
fragments = _tokenize_unicode_words(cleaned, min_length=1)
|
|
993
|
+
# Pick the longest fragment that meets the minimum length, falling back
|
|
994
|
+
# to the longest fragment overall so annotated words are never discarded.
|
|
995
|
+
valid = [f for f in fragments if len(f) >= WordBagRule.MIN_WORD_LENGTH]
|
|
996
|
+
if valid:
|
|
997
|
+
self.normalized_word: str = max(valid, key=len)
|
|
998
|
+
elif fragments:
|
|
999
|
+
# For contractions like "can't" → ['can', 't'], join all
|
|
1000
|
+
# fragments to form a single token ("cant", 4 chars) instead
|
|
1001
|
+
# of picking the longest short fragment.
|
|
1002
|
+
joined = "".join(fragments)
|
|
1003
|
+
if len(joined) >= WordBagRule.MIN_WORD_LENGTH:
|
|
1004
|
+
self.normalized_word = joined
|
|
1005
|
+
else:
|
|
1006
|
+
self.normalized_word = max(fragments, key=len)
|
|
1007
|
+
else:
|
|
1008
|
+
raise ValueError(f"word is empty after normalization: {raw_word!r}")
|
|
1009
|
+
self.actual_words: Counter[str] | None = None
|
|
1010
|
+
self.apostrophe_stripped_content: str | None = None
|
|
1011
|
+
|
|
1012
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str]:
|
|
1013
|
+
actual_words = self.actual_words
|
|
1014
|
+
if actual_words is None:
|
|
1015
|
+
actual_words = WordBagRule._extract_normalized_words_static(
|
|
1016
|
+
md_content,
|
|
1017
|
+
include_table_cells=True,
|
|
1018
|
+
)
|
|
1019
|
+
if self.normalized_word in actual_words:
|
|
1020
|
+
return True, ""
|
|
1021
|
+
# Fallback: search in apostrophe-stripped normalized content.
|
|
1022
|
+
# Handles contractions (e.g. "can't" → "cant") where the token-based
|
|
1023
|
+
# approach splits on the apostrophe and individual fragments are too
|
|
1024
|
+
# short to survive the MIN_WORD_LENGTH filter.
|
|
1025
|
+
content = self.apostrophe_stripped_content
|
|
1026
|
+
if content is None:
|
|
1027
|
+
# Standalone rule usage recomputes normalized content here; the
|
|
1028
|
+
# RuleBasedMetric cache path reuses its pre-normalized document text.
|
|
1029
|
+
content = normalize_text(md_content)
|
|
1030
|
+
content = self.strip_apostrophes(content)
|
|
1031
|
+
if _word_boundary_count(self.normalized_word, content) > 0:
|
|
1032
|
+
return True, ""
|
|
1033
|
+
return False, f"Missing specific word: '{self.normalized_word}'"
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
# --- Digit count patterns for bag_of_digit_percent ---
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _extract_digit_counts(md_content: str, include_table_cells: bool = False) -> Counter[str]:
|
|
1040
|
+
"""Extract digit (0-9) occurrence counts from markdown content.
|
|
1041
|
+
|
|
1042
|
+
Digits inside HTML tag attributes (e.g. colspan="2", rowspan="3") are excluded.
|
|
1043
|
+
Digits inside table cell text content ARE included.
|
|
1044
|
+
"""
|
|
1045
|
+
if include_table_cells:
|
|
1046
|
+
md_content = _augment_with_table_cell_text(md_content)
|
|
1047
|
+
|
|
1048
|
+
# Remove HTML tags (including attributes) but keep their text content
|
|
1049
|
+
content = _HTML_TAG_WITH_ATTRS_PATTERN.sub(" ", md_content)
|
|
1050
|
+
# Count each digit character
|
|
1051
|
+
return Counter(ch for ch in content if ch in "0123456789")
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
class BagOfDigitPercentRule(ParseTestRule):
|
|
1055
|
+
"""Score digit-frequency match between expected and actual markdown in [0, 1].
|
|
1056
|
+
|
|
1057
|
+
Compares the count of each digit (0-9) in the actual output against the
|
|
1058
|
+
expected counts in ``bag_of_digit``. Digits inside HTML tag attributes
|
|
1059
|
+
(e.g. ``colspan="2"``) are excluded so only meaningful content digits
|
|
1060
|
+
are compared.
|
|
1061
|
+
|
|
1062
|
+
Score = matched / total_expected, where matched is the sum of
|
|
1063
|
+
min(actual_count, expected_count) for each digit.
|
|
1064
|
+
"""
|
|
1065
|
+
|
|
1066
|
+
def __init__(self, rule_data: ParseBagOfDigitPercentRule | dict):
|
|
1067
|
+
super().__init__(rule_data)
|
|
1068
|
+
rule_data = cast(ParseBagOfDigitPercentRule, self._rule_data)
|
|
1069
|
+
|
|
1070
|
+
if self.type != TestType.BAG_OF_DIGIT_PERCENT.value:
|
|
1071
|
+
raise ValueError(f"Invalid type for BagOfDigitPercentRule: {self.type}")
|
|
1072
|
+
|
|
1073
|
+
bag_of_digit = rule_data.bag_of_digit
|
|
1074
|
+
if not isinstance(bag_of_digit, dict) or not bag_of_digit:
|
|
1075
|
+
raise ValueError("bag_of_digit must be a non-empty dictionary")
|
|
1076
|
+
|
|
1077
|
+
self.digit_bag: Counter[str] = Counter()
|
|
1078
|
+
for digit, count in bag_of_digit.items():
|
|
1079
|
+
if not isinstance(digit, str) or digit not in "0123456789":
|
|
1080
|
+
raise ValueError(f"bag_of_digit keys must be single digit characters (0-9), got: {digit!r}")
|
|
1081
|
+
if not isinstance(count, int):
|
|
1082
|
+
raise ValueError("bag_of_digit values must be integers")
|
|
1083
|
+
if count < 0:
|
|
1084
|
+
raise ValueError("bag_of_digit values cannot be negative")
|
|
1085
|
+
self.digit_bag[digit] += count
|
|
1086
|
+
|
|
1087
|
+
if not self.digit_bag:
|
|
1088
|
+
raise ValueError("bag_of_digit has no valid digits")
|
|
1089
|
+
|
|
1090
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
|
|
1091
|
+
actual_digits = _extract_digit_counts(md_content, include_table_cells=True)
|
|
1092
|
+
|
|
1093
|
+
total_expected = sum(self.digit_bag.values())
|
|
1094
|
+
if total_expected == 0:
|
|
1095
|
+
return True, "", 1.0
|
|
1096
|
+
|
|
1097
|
+
matched = 0
|
|
1098
|
+
missing: list[tuple[str, int, int, int]] = []
|
|
1099
|
+
|
|
1100
|
+
for digit, expected_count in self.digit_bag.items():
|
|
1101
|
+
actual_count = actual_digits.get(digit, 0)
|
|
1102
|
+
matched += min(actual_count, expected_count)
|
|
1103
|
+
if actual_count < expected_count:
|
|
1104
|
+
missing.append((digit, expected_count - actual_count, actual_count, expected_count))
|
|
1105
|
+
|
|
1106
|
+
score = max(0.0, min(1.0, matched / total_expected))
|
|
1107
|
+
|
|
1108
|
+
if not missing:
|
|
1109
|
+
return True, "", score
|
|
1110
|
+
|
|
1111
|
+
missing.sort(key=lambda item: (-item[1], item[0]))
|
|
1112
|
+
preview = "; ".join(f"'{d}' ({actual}<{expected})" for d, _, actual, expected in missing[:5])
|
|
1113
|
+
return (
|
|
1114
|
+
False,
|
|
1115
|
+
f"Bag of digit percent score={score:.3f}; missing: {preview}",
|
|
1116
|
+
score,
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
class ExtraContentRule(SentenceBagRule):
|
|
1121
|
+
"""Backward-compatible combined extra-content check.
|
|
1122
|
+
|
|
1123
|
+
This remains available for older datasets and is equivalent to:
|
|
1124
|
+
- Unexpected sentence detection (sentence not listed in bag_of_sentence), and
|
|
1125
|
+
- Too many occurrence detection (sentence count exceeds allowed value).
|
|
1126
|
+
|
|
1127
|
+
An actual sentence is considered expected if it matches a bag entry
|
|
1128
|
+
exactly or is a substring of any bag entry (handles boundary misalignment).
|
|
1129
|
+
"""
|
|
1130
|
+
|
|
1131
|
+
def __init__(self, rule_data: ParseExtraContentRule):
|
|
1132
|
+
super().__init__(rule_data, TestType.EXTRA_CONTENT.value)
|
|
1133
|
+
self._bag_full_text: str = " ".join(self.sentence_bag.keys())
|
|
1134
|
+
|
|
1135
|
+
def _expected_count(self, sentence: str) -> int:
|
|
1136
|
+
"""Return the expected count, using substring fallback for unrecognized sentences."""
|
|
1137
|
+
exact = self.sentence_bag.get(sentence, 0)
|
|
1138
|
+
if exact > 0:
|
|
1139
|
+
return exact
|
|
1140
|
+
# Substring fallback: if the sentence is a sub-piece of a bag entry, treat as expected once
|
|
1141
|
+
if sentence in self._bag_full_text:
|
|
1142
|
+
return 1
|
|
1143
|
+
return 0
|
|
1144
|
+
|
|
1145
|
+
def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str]:
|
|
1146
|
+
actual_sentence_bag = self._extract_normalized_sentences(md_content)
|
|
1147
|
+
|
|
1148
|
+
extras: list[tuple[str, int]] = []
|
|
1149
|
+
for sentence, actual_count in actual_sentence_bag.items():
|
|
1150
|
+
expected_count = self._expected_count(sentence)
|
|
1151
|
+
if actual_count > expected_count:
|
|
1152
|
+
extras.append((sentence, actual_count - expected_count))
|
|
1153
|
+
|
|
1154
|
+
if not extras:
|
|
1155
|
+
return True, ""
|
|
1156
|
+
|
|
1157
|
+
extras.sort(key=lambda item: (-item[1], item[0]))
|
|
1158
|
+
preview = "; ".join(
|
|
1159
|
+
f"{self._format_sentence_debug(sentence)} (+{extra_count})" for sentence, extra_count in extras[:5]
|
|
1160
|
+
)
|
|
1161
|
+
return False, f"Found extra content sentences: {preview}"
|