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.
Files changed (227) hide show
  1. parse_bench/__init__.py +3 -0
  2. parse_bench/analysis/__init__.py +6 -0
  3. parse_bench/analysis/aggregation_report.py +582 -0
  4. parse_bench/analysis/cli.py +472 -0
  5. parse_bench/analysis/comparison.py +382 -0
  6. parse_bench/analysis/comparison_core.py +357 -0
  7. parse_bench/analysis/comparison_report.py +2066 -0
  8. parse_bench/analysis/detailed_report.py +2254 -0
  9. parse_bench/analysis/leaderboard_report.py +852 -0
  10. parse_bench/analysis/metric_definitions.py +771 -0
  11. parse_bench/cli.py +267 -0
  12. parse_bench/data/__init__.py +1 -0
  13. parse_bench/data/cli.py +118 -0
  14. parse_bench/data/download.py +127 -0
  15. parse_bench/evaluation/__init__.py +11 -0
  16. parse_bench/evaluation/cli.py +435 -0
  17. parse_bench/evaluation/evaluators/__init__.py +17 -0
  18. parse_bench/evaluation/evaluators/base.py +34 -0
  19. parse_bench/evaluation/evaluators/extract.py +429 -0
  20. parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
  21. parse_bench/evaluation/evaluators/parse.py +1353 -0
  22. parse_bench/evaluation/evaluators/qa.py +199 -0
  23. parse_bench/evaluation/layout_adapters/__init__.py +21 -0
  24. parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
  25. parse_bench/evaluation/layout_adapters/base.py +105 -0
  26. parse_bench/evaluation/layout_adapters/registry.py +109 -0
  27. parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
  28. parse_bench/evaluation/layout_label_mappers/base.py +66 -0
  29. parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
  30. parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
  31. parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
  32. parse_bench/evaluation/metric_aggregation.py +56 -0
  33. parse_bench/evaluation/metrics/__init__.py +5 -0
  34. parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
  35. parse_bench/evaluation/metrics/attribution/constants.py +12 -0
  36. parse_bench/evaluation/metrics/attribution/core.py +1108 -0
  37. parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
  38. parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
  39. parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
  40. parse_bench/evaluation/metrics/base.py +33 -0
  41. parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
  42. parse_bench/evaluation/metrics/extract/__init__.py +29 -0
  43. parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
  44. parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
  45. parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
  46. parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
  47. parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
  48. parse_bench/evaluation/metrics/extract/test_types.py +11 -0
  49. parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
  50. parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
  51. parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
  52. parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
  53. parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
  54. parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
  55. parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
  56. parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
  57. parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
  58. parse_bench/evaluation/metrics/parse/__init__.py +5 -0
  59. parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
  60. parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
  61. parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
  62. parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
  63. parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
  64. parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
  65. parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
  66. parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
  67. parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
  68. parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
  69. parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
  70. parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
  71. parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
  72. parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
  73. parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
  74. parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
  75. parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
  76. parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
  77. parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
  78. parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
  79. parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
  80. parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
  81. parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
  82. parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
  83. parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
  84. parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
  85. parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
  86. parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
  87. parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
  88. parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
  89. parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
  90. parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
  91. parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
  92. parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
  93. parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
  94. parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
  95. parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
  96. parse_bench/evaluation/metrics/parse/test_types.py +103 -0
  97. parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
  98. parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
  99. parse_bench/evaluation/metrics/parse/utils.py +885 -0
  100. parse_bench/evaluation/metrics/qa/__init__.py +5 -0
  101. parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
  102. parse_bench/evaluation/qa/__init__.py +5 -0
  103. parse_bench/evaluation/qa/llm_service.py +335 -0
  104. parse_bench/evaluation/reports/__init__.py +8 -0
  105. parse_bench/evaluation/reports/csv.py +64 -0
  106. parse_bench/evaluation/reports/html.py +338 -0
  107. parse_bench/evaluation/reports/markdown.py +98 -0
  108. parse_bench/evaluation/reports/rule_csv.py +22 -0
  109. parse_bench/evaluation/runner.py +1864 -0
  110. parse_bench/evaluation/stats.py +104 -0
  111. parse_bench/extensions.py +72 -0
  112. parse_bench/inference/__init__.py +33 -0
  113. parse_bench/inference/chunkr_layout_extraction.py +160 -0
  114. parse_bench/inference/cli.py +484 -0
  115. parse_bench/inference/layout_extraction.py +422 -0
  116. parse_bench/inference/pipelines/__init__.py +59 -0
  117. parse_bench/inference/pipelines/extract.py +39 -0
  118. parse_bench/inference/pipelines/layout.py +142 -0
  119. parse_bench/inference/pipelines/parse.py +2603 -0
  120. parse_bench/inference/pipelines.py +0 -0
  121. parse_bench/inference/providers/__init__.py +28 -0
  122. parse_bench/inference/providers/base.py +196 -0
  123. parse_bench/inference/providers/cancellation.py +137 -0
  124. parse_bench/inference/providers/extract/__init__.py +22 -0
  125. parse_bench/inference/providers/extract/citations.py +549 -0
  126. parse_bench/inference/providers/extract/extend.py +851 -0
  127. parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
  128. parse_bench/inference/providers/layoutdet/__init__.py +25 -0
  129. parse_bench/inference/providers/layoutdet/adapters.py +946 -0
  130. parse_bench/inference/providers/layoutdet/base.py +203 -0
  131. parse_bench/inference/providers/layoutdet/chandra.py +449 -0
  132. parse_bench/inference/providers/layoutdet/docling.py +125 -0
  133. parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
  134. parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
  135. parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
  136. parse_bench/inference/providers/layoutdet/paddle.py +117 -0
  137. parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
  138. parse_bench/inference/providers/layoutdet/surya.py +250 -0
  139. parse_bench/inference/providers/layoutdet/yolo.py +109 -0
  140. parse_bench/inference/providers/parse/__init__.py +64 -0
  141. parse_bench/inference/providers/parse/_docling_common.py +233 -0
  142. parse_bench/inference/providers/parse/_layout_utils.py +611 -0
  143. parse_bench/inference/providers/parse/amazon_nova.py +515 -0
  144. parse_bench/inference/providers/parse/anthropic.py +882 -0
  145. parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
  146. parse_bench/inference/providers/parse/chandra2.py +633 -0
  147. parse_bench/inference/providers/parse/chunkr.py +268 -0
  148. parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
  149. parse_bench/inference/providers/parse/datalab.py +370 -0
  150. parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
  151. parse_bench/inference/providers/parse/docling.py +281 -0
  152. parse_bench/inference/providers/parse/docling_serve.py +289 -0
  153. parse_bench/inference/providers/parse/dots_ocr.py +574 -0
  154. parse_bench/inference/providers/parse/extend_parse.py +710 -0
  155. parse_bench/inference/providers/parse/falconocr.py +436 -0
  156. parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
  157. parse_bench/inference/providers/parse/gemma4.py +472 -0
  158. parse_bench/inference/providers/parse/glm_zai.py +229 -0
  159. parse_bench/inference/providers/parse/google.py +1125 -0
  160. parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
  161. parse_bench/inference/providers/parse/google_docai.py +776 -0
  162. parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
  163. parse_bench/inference/providers/parse/granite_vision.py +515 -0
  164. parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
  165. parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
  166. parse_bench/inference/providers/parse/landingai.py +452 -0
  167. parse_bench/inference/providers/parse/liteparse.py +350 -0
  168. parse_bench/inference/providers/parse/llamaparse.py +677 -0
  169. parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
  170. parse_bench/inference/providers/parse/markitdown.py +138 -0
  171. parse_bench/inference/providers/parse/mineru25.py +405 -0
  172. parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
  173. parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
  174. parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
  175. parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
  176. parse_bench/inference/providers/parse/oi_parser.py +222 -0
  177. parse_bench/inference/providers/parse/openai.py +740 -0
  178. parse_bench/inference/providers/parse/opendataloader.py +152 -0
  179. parse_bench/inference/providers/parse/paddleocr.py +624 -0
  180. parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
  181. parse_bench/inference/providers/parse/pulse.py +785 -0
  182. parse_bench/inference/providers/parse/pymupdf.py +207 -0
  183. parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
  184. parse_bench/inference/providers/parse/pypdf.py +179 -0
  185. parse_bench/inference/providers/parse/qwen.py +678 -0
  186. parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
  187. parse_bench/inference/providers/parse/reducto.py +546 -0
  188. parse_bench/inference/providers/parse/surya2.py +372 -0
  189. parse_bench/inference/providers/parse/tesseract.py +301 -0
  190. parse_bench/inference/providers/parse/textract.py +694 -0
  191. parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
  192. parse_bench/inference/providers/parse/unstructured.py +485 -0
  193. parse_bench/inference/providers/parse/warp_ingest.py +199 -0
  194. parse_bench/inference/providers/registry.py +49 -0
  195. parse_bench/inference/renormalize.py +170 -0
  196. parse_bench/inference/runner.py +2023 -0
  197. parse_bench/layout_label_mapping.py +424 -0
  198. parse_bench/layout_projection.py +179 -0
  199. parse_bench/pipeline/__init__.py +1 -0
  200. parse_bench/pipeline/cli.py +549 -0
  201. parse_bench/schemas/__init__.py +33 -0
  202. parse_bench/schemas/evaluation.py +93 -0
  203. parse_bench/schemas/extract_output.py +36 -0
  204. parse_bench/schemas/layout_detection_output.py +545 -0
  205. parse_bench/schemas/layout_ontology.py +315 -0
  206. parse_bench/schemas/metrics.py +69 -0
  207. parse_bench/schemas/parse_output.py +152 -0
  208. parse_bench/schemas/pipeline.py +22 -0
  209. parse_bench/schemas/pipeline_io.py +106 -0
  210. parse_bench/schemas/product.py +97 -0
  211. parse_bench/test_cases/__init__.py +25 -0
  212. parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
  213. parse_bench/test_cases/extract_field_paths.py +164 -0
  214. parse_bench/test_cases/layout_attribution_generation.py +287 -0
  215. parse_bench/test_cases/loader.py +652 -0
  216. parse_bench/test_cases/parse_rule_schemas.py +1071 -0
  217. parse_bench/test_cases/rule_filters.py +32 -0
  218. parse_bench/test_cases/rule_ids.py +107 -0
  219. parse_bench/test_cases/schema.py +427 -0
  220. parse_bench/utils/__init__.py +15 -0
  221. parse_bench/utils/gemini_layout_utils.py +670 -0
  222. parse_bench/utils/text_aggregation.py +100 -0
  223. parse_bench-1.0.0.dist-info/METADATA +476 -0
  224. parse_bench-1.0.0.dist-info/RECORD +227 -0
  225. parse_bench-1.0.0.dist-info/WHEEL +4 -0
  226. parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
  227. 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: ![alt text](url) — 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 (&lt;h1&gt;) is treated
206
+ # the same as raw markup (<h1>) and plain symbols (&lt;) 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}"