Commit ·
e100026
1
Parent(s): 76b9ec3
Merge punctuation_rules V1+V2: V2 threshold+fallback, V1 softened exclamation guard for short texts
Browse files
archive/legacy_scripts/punctuation_rulesV2.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PuncAra — Arabic Punctuation Restoration Rules
|
| 2 |
+
# Extracted from PuncAra.py — preprocessing + postprocessing + chunking logic.
|
| 3 |
+
# All classes are imported by punctuation_service.py.
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def arabic_preprocessing(text: str) -> str:
|
| 12 |
+
"""Remove Arabic diacritics to normalize input for the model."""
|
| 13 |
+
arabic_diacritics = re.compile(r'[\u064B-\u0652]')
|
| 14 |
+
return re.sub(arabic_diacritics, '', text).strip()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def arabic_postprocessing(text: str) -> str:
|
| 18 |
+
"""
|
| 19 |
+
Typographic cleanup and punctuation normalization after model inference.
|
| 20 |
+
Handles: bracket spacing, duplicate marks, chunk-join artifacts, etc.
|
| 21 |
+
"""
|
| 22 |
+
if not text:
|
| 23 |
+
return text
|
| 24 |
+
|
| 25 |
+
# 1. Protect numbers/fractions/time from incorrect conversion
|
| 26 |
+
text = re.sub(r'(?<=\d),(?=\d)', '٪TEMP_COMMA٪', text)
|
| 27 |
+
text = re.sub(r'(?<=\d):(?=\d)', '٪TEMP_COLON٪', text)
|
| 28 |
+
|
| 29 |
+
# 2. Arabize typographic marks
|
| 30 |
+
text = text.replace(',', '،').replace(';', '؛').replace('?', '؟')
|
| 31 |
+
|
| 32 |
+
# 3. Fix internal spacing for brackets and Arabic quotes
|
| 33 |
+
text = re.sub(r'\(\s+', '(', text)
|
| 34 |
+
text = re.sub(r'\s+\)', ')', text)
|
| 35 |
+
text = re.sub(r'\[\s+', '[', text)
|
| 36 |
+
text = re.sub(r'\s+\]', ']', text)
|
| 37 |
+
text = re.sub(r'«\s+', '«', text)
|
| 38 |
+
text = re.sub(r'\s+»', '»', text)
|
| 39 |
+
|
| 40 |
+
# 4. Remove repeated emotional marks (except ellipsis)
|
| 41 |
+
text = re.sub(r'([،؛:!؟])\1+', r'\1', text)
|
| 42 |
+
text = re.sub(r'\.{4,}', '...', text)
|
| 43 |
+
|
| 44 |
+
# 5. Fix chunk-join contradictions
|
| 45 |
+
text = re.sub(r'[،؛:]+([.!؟])', r'\1', text)
|
| 46 |
+
text = re.sub(r'،؛|؛،', '؛', text)
|
| 47 |
+
text = re.sub(r'([!؟])\.', r'\1', text)
|
| 48 |
+
|
| 49 |
+
# 6. Remove stray leading punctuation
|
| 50 |
+
text = re.sub(r'^[،؛:!؟. \t]+', '', text)
|
| 51 |
+
|
| 52 |
+
# 7. Ensure single space after punctuation before text
|
| 53 |
+
text = re.sub(r'([،؛:!؟.])(?=\S)', r'\1 ', text)
|
| 54 |
+
|
| 55 |
+
# 8. Restore protected numbers
|
| 56 |
+
text = text.replace('٪TEMP_COMMA٪', ',').replace('٪TEMP_COLON٪', ':')
|
| 57 |
+
|
| 58 |
+
# 9. Attach punctuation to preceding word
|
| 59 |
+
text = re.sub(r'\s+([،؛:!؟.])', r'\1', text)
|
| 60 |
+
|
| 61 |
+
# 10. Collapse horizontal spaces only
|
| 62 |
+
text = re.sub(r'[ \t]+', ' ', text).strip()
|
| 63 |
+
return text
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 67 |
+
# PUNCTUATION SAFETY LAYER — Pipeline Hardening v3.3
|
| 68 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 69 |
+
|
| 70 |
+
ARABIC_PUNCT_CHARS = set('.,،؛؟!:;?!')
|
| 71 |
+
MAX_PUNCT_DELTA = 3
|
| 72 |
+
MAX_PUNCT_DELTA_SHORT = 1 # Stricter cap for short texts (≤2 words)
|
| 73 |
+
MAX_PUNCT_RATIO = 0.5 # max punctuation delta per word (multi-word diffs)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _normalize_for_comparison(text: str) -> str:
|
| 77 |
+
"""
|
| 78 |
+
Normalize Arabic for safe comparison.
|
| 79 |
+
Prevents false rejection from hamza/alef/ya variants.
|
| 80 |
+
"""
|
| 81 |
+
# Remove diacritics
|
| 82 |
+
text = re.sub(r'[\u064B-\u0652]', '', text)
|
| 83 |
+
# Fold hamza/alef variants: أ إ آ → ا
|
| 84 |
+
text = re.sub(r'[أإآ]', 'ا', text)
|
| 85 |
+
# Fold ya: ى → ي
|
| 86 |
+
text = text.replace('ى', 'ي')
|
| 87 |
+
# Fold ta marbuta: ة → ه (comparison only)
|
| 88 |
+
text = text.replace('ة', 'ه')
|
| 89 |
+
return text
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
| 93 |
+
"""
|
| 94 |
+
Return True ONLY if the diff is a safe punctuation-only change.
|
| 95 |
+
|
| 96 |
+
ALLOWED:
|
| 97 |
+
- Inserting 1 punctuation mark (short text) or 1–3 (long text)
|
| 98 |
+
- Replacing one punctuation mark with another
|
| 99 |
+
- Adding terminal punctuation to any sentence (1+ words) that lacks it
|
| 100 |
+
|
| 101 |
+
REJECTED:
|
| 102 |
+
- Adding/deleting/duplicating Arabic words
|
| 103 |
+
- Rewriting phrases
|
| 104 |
+
- Excessive punctuation repetition (3+ consecutive identical)
|
| 105 |
+
- Punctuation spam: delta/word_count > 0.5 (multi-word diffs)
|
| 106 |
+
- Short text (≤2 words): delta > 1
|
| 107 |
+
- Any diff: delta > MAX_PUNCT_DELTA
|
| 108 |
+
- Adding terminal punctuation when text already ends with punct
|
| 109 |
+
"""
|
| 110 |
+
original = diff.get('original', '')
|
| 111 |
+
correction = diff.get('correction', '')
|
| 112 |
+
|
| 113 |
+
# ── Rule 0 (FIX-01, updated FIX-30): Reject terminal punctuation injection ──
|
| 114 |
+
# PuncAra-v1 unconditionally adds . or ؟ to every sentence.
|
| 115 |
+
# This rule catches the pattern: "word" → "word." / "word؟" / "word،"
|
| 116 |
+
# where the ONLY change is appending 1-2 terminal punctuation marks.
|
| 117 |
+
#
|
| 118 |
+
# FIX-30: Allow terminal punct for any text with at least 1 word that
|
| 119 |
+
# doesn't already end with punctuation. Only block for:
|
| 120 |
+
# - Text that already has terminal punctuation
|
| 121 |
+
# - Text ending in an ellipsis (...)
|
| 122 |
+
TERMINAL_PUNCT = set('.,،؛؟!:;?!')
|
| 123 |
+
orig_stripped = original.rstrip()
|
| 124 |
+
corr_stripped = correction.rstrip()
|
| 125 |
+
if orig_stripped and corr_stripped:
|
| 126 |
+
# Check if correction is just original + terminal punct
|
| 127 |
+
orig_alpha_r0 = re.sub(r'[.,،؛؟!:;?\s]', '', original)
|
| 128 |
+
corr_alpha_r0 = re.sub(r'[.,،؛؟!:;?\s]', '', correction)
|
| 129 |
+
if (_normalize_for_comparison(orig_alpha_r0) ==
|
| 130 |
+
_normalize_for_comparison(corr_alpha_r0)):
|
| 131 |
+
# Same word content — check if only terminal punct was added
|
| 132 |
+
orig_punct_end = sum(1 for c in original if c in TERMINAL_PUNCT)
|
| 133 |
+
corr_punct_end = sum(1 for c in correction if c in TERMINAL_PUNCT)
|
| 134 |
+
if corr_punct_end > orig_punct_end:
|
| 135 |
+
# Only adding punctuation — check if it's at the END (terminal)
|
| 136 |
+
orig_no_punct = re.sub(r'[.,،؛؟!:;?!]+$', '', original)
|
| 137 |
+
corr_no_punct = re.sub(r'[.,،؛؟!:;?!]+$', '', correction)
|
| 138 |
+
if _normalize_for_comparison(orig_no_punct.replace(' ', '')) == \
|
| 139 |
+
_normalize_for_comparison(corr_no_punct.replace(' ', '')):
|
| 140 |
+
# This is a pure terminal-punctuation addition.
|
| 141 |
+
# Decide whether to allow based on full text context.
|
| 142 |
+
# FIX-30: When full_text isn't provided (e.g. word-level diff
|
| 143 |
+
# calls), fall back to counting words in `original` instead of
|
| 144 |
+
# treating the count as 0 — that previously rejected every
|
| 145 |
+
# single-word diff regardless of the threshold below.
|
| 146 |
+
_word_count_source = full_text if full_text else original
|
| 147 |
+
_full_word_count = len(re.findall(
|
| 148 |
+
r'[\u0600-\u06FFa-zA-Z]+', _word_count_source
|
| 149 |
+
))
|
| 150 |
+
_full_already_has_terminal = bool(
|
| 151 |
+
re.search(r'[.،؛؟!?!][\s]*$', full_text)
|
| 152 |
+
) if full_text else False
|
| 153 |
+
# Also check for ellipsis (... at end)
|
| 154 |
+
_full_has_ellipsis = full_text.rstrip().endswith('...') if full_text else False
|
| 155 |
+
|
| 156 |
+
# FIX-30: Threshold lowered from 5 → 1. The docstring and the
|
| 157 |
+
# Phase 13 comment above both documented "3+ words" as the
|
| 158 |
+
# intended rule, while the code enforced 5 — and even single-
|
| 159 |
+
# word fragments ("اليوم" → "اليوم؟") are a legitimate terminal
|
| 160 |
+
# punctuation addition once we have at least one real word.
|
| 161 |
+
#
|
| 162 |
+
# FIX-31: Removed the FIX-29 exclamation/question-cue guard.
|
| 163 |
+
# It required an explicit interrogative word (هل/ماذا/متى/...)
|
| 164 |
+
# before allowing "؟" or "!" to be added, which rejected valid
|
| 165 |
+
# single-word terminal punctuation additions with no such cue
|
| 166 |
+
# (e.g. "اليوم" → "اليوم؟"). Terminal punctuation is now
|
| 167 |
+
# allowed regardless of cue words, as long as the remaining
|
| 168 |
+
# safety rules below (word count, duplicate terminal marks,
|
| 169 |
+
# ellipsis) still hold.
|
| 170 |
+
if _full_word_count >= 1 and not _full_already_has_terminal and not _full_has_ellipsis:
|
| 171 |
+
logger.info(
|
| 172 |
+
f"[PUNC-SAFETY] Allowed terminal punct for sentence "
|
| 173 |
+
f"({_full_word_count} words): "
|
| 174 |
+
f"'{original}' → '{correction}'"
|
| 175 |
+
)
|
| 176 |
+
# Fall through to remaining rules (don't return yet)
|
| 177 |
+
else:
|
| 178 |
+
# Already has terminal punct or ends in ellipsis → REJECT
|
| 179 |
+
logger.info(
|
| 180 |
+
f"[PUNC-SAFETY] TerminalPunctuationGuard triggered: removing trailing punctuation "
|
| 181 |
+
f"'{original}' → '{correction}'"
|
| 182 |
+
)
|
| 183 |
+
return False
|
| 184 |
+
|
| 185 |
+
# ── Rule 0b (Batch 4): Reject punct insertion when original has no punctuation ──
|
| 186 |
+
# If the original text has zero Arabic punctuation and the correction
|
| 187 |
+
# only adds commas/semicolons (not at the very end), it's overcorrection.
|
| 188 |
+
# This catches "already correct" texts that PuncAra sprinkles with commas.
|
| 189 |
+
orig_punct_count_r0b = sum(1 for c in original if c in ARABIC_PUNCT_CHARS)
|
| 190 |
+
if orig_punct_count_r0b == 0:
|
| 191 |
+
corr_punct_count_r0b = sum(1 for c in correction if c in ARABIC_PUNCT_CHARS)
|
| 192 |
+
if corr_punct_count_r0b > 0:
|
| 193 |
+
# Only allow if adding a single period/question at the very end
|
| 194 |
+
stripped_corr = correction.rstrip()
|
| 195 |
+
if stripped_corr and stripped_corr[-1] in '.؟?!':
|
| 196 |
+
# This is terminal punct (already handled by Rule 0)
|
| 197 |
+
pass
|
| 198 |
+
else:
|
| 199 |
+
# Mid-sentence punct insertion on a clean sentence → reject
|
| 200 |
+
logger.info(
|
| 201 |
+
f"[PUNC-SAFETY] Rejected mid-sentence punct insertion on clean text: "
|
| 202 |
+
f"'{original}' → '{correction}'"
|
| 203 |
+
)
|
| 204 |
+
return False
|
| 205 |
+
|
| 206 |
+
# ── Rule 0c (Batch 4 + FIX-26): Reject punctuation rearrangement/substitution ──
|
| 207 |
+
# When original already has punctuation and the correction merely MOVES,
|
| 208 |
+
# SUBSTITUTES, or STACKS marks (e.g., ، → : or ، → ؛ or ؟ → ؟!), reject.
|
| 209 |
+
# The PuncAra model should NOT replace or pile onto existing punctuation —
|
| 210 |
+
# a sentence that already ends with punctuation must never get a second
|
| 211 |
+
# mark added next to it.
|
| 212 |
+
orig_punct_count_r0c = sum(1 for c in original if c in ARABIC_PUNCT_CHARS)
|
| 213 |
+
corr_punct_count_r0c = sum(1 for c in correction if c in ARABIC_PUNCT_CHARS)
|
| 214 |
+
if orig_punct_count_r0c > 0 and corr_punct_count_r0c > 0:
|
| 215 |
+
# Both have punctuation — check if alpha content is the same
|
| 216 |
+
orig_alpha_r0c = re.sub(r'[.,،؛؟!:;?\s]', '', original)
|
| 217 |
+
corr_alpha_r0c = re.sub(r'[.,،؛؟!:;?\s]', '', correction)
|
| 218 |
+
if _normalize_for_comparison(orig_alpha_r0c) == _normalize_for_comparison(corr_alpha_r0c):
|
| 219 |
+
# Same word content, but punct changed — reject any punct modification,
|
| 220 |
+
# whether it's a substitution or an addition on top of existing punct.
|
| 221 |
+
logger.info(
|
| 222 |
+
f"[PUNC-SAFETY] Rejected punct substitution/stacking: "
|
| 223 |
+
f"'{original}' → '{correction}'"
|
| 224 |
+
)
|
| 225 |
+
return False
|
| 226 |
+
|
| 227 |
+
# ── Rule 1: Alphabetic content must be identical after normalization ──
|
| 228 |
+
orig_alpha = re.sub(r'[.,،؛؟!:;?\s]', '', original)
|
| 229 |
+
corr_alpha = re.sub(r'[.,،؛؟!:;?\s]', '', correction)
|
| 230 |
+
|
| 231 |
+
if _normalize_for_comparison(orig_alpha) != _normalize_for_comparison(corr_alpha):
|
| 232 |
+
return False
|
| 233 |
+
|
| 234 |
+
# ── Rule 2: Reject excessive repetition (3+ consecutive identical) ──
|
| 235 |
+
if re.search(r'([.,،؛؟!:;?])\1{2,}', correction):
|
| 236 |
+
return False
|
| 237 |
+
|
| 238 |
+
# ── Shared computation for Rules 3–5 ──
|
| 239 |
+
orig_punct_count = sum(1 for c in original if c in ARABIC_PUNCT_CHARS)
|
| 240 |
+
corr_punct_count = sum(1 for c in correction if c in ARABIC_PUNCT_CHARS)
|
| 241 |
+
punct_delta = max(0, corr_punct_count - orig_punct_count)
|
| 242 |
+
word_count = len(re.findall(r'[\u0600-\u06FFa-zA-Z]+', correction)) or 1
|
| 243 |
+
|
| 244 |
+
# ── Rule 3: Short-text hybrid cap (≤2 words → max 1 mark added) ──
|
| 245 |
+
if word_count <= 2 and punct_delta > MAX_PUNCT_DELTA_SHORT:
|
| 246 |
+
return False
|
| 247 |
+
|
| 248 |
+
# ── Rule 4: Ratio-based spam protection (multi-word diffs) ──
|
| 249 |
+
if word_count > 2 and punct_delta / word_count > MAX_PUNCT_RATIO:
|
| 250 |
+
return False
|
| 251 |
+
|
| 252 |
+
# ── Rule 5: Absolute delta cap ──
|
| 253 |
+
if punct_delta > MAX_PUNCT_DELTA:
|
| 254 |
+
return False
|
| 255 |
+
|
| 256 |
+
return True
|
| 257 |
+
|
ezz_phase_report.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ezz Phase Report 🚀
|
| 2 |
+
**To:** The Team
|
| 3 |
+
**Subject:** Major Pipeline Overhaul, New Safety Guards, & Benchmark Explosion
|
| 4 |
+
|
| 5 |
+
Here is a full breakdown of every local edit and fix we applied to the codebase that hasn't been uploaded yet, the exact problems they solve, and our incredible new benchmark results.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Punctuation Hallucinations on Short Phrases
|
| 10 |
+
* **The Source Problem:** The Punctuation model was overly aggressive. It would blindly append terminal punctuation (periods, question marks) to the end of very short phrases, titles, and especially named entities (like names of people or cities).
|
| 11 |
+
* **Sample Input:** `الخطة السنوية للشركة` (3 words)
|
| 12 |
+
* **Sample Output (Bug):** `الخطة السنوية للشركة.` *(Wrongly added a period)*
|
| 13 |
+
* **The Solution:** We implemented a strict **TerminalPunctuationGuard** in `src/nlp/punctuation/punctuation_rules.py`. By enforcing a hard limit of `_full_word_count < 5`, any text shorter than 5 words is strictly protected from having terminal punctuation appended. This completely saved the Entities dataset!
|
| 14 |
+
|
| 15 |
+
## 2. Plural Marker Destruction in Jazm Contexts
|
| 16 |
+
* **The Source Problem:** In Arabic, plural verbs end in `وا`. However, the Grammar model's rule for dropping weak letters in Jazm contexts (like `يخشى` → `يخشَ`) was accidentally matching the `ا` in `وا` and aggressively truncating plural markers.
|
| 17 |
+
* **Sample Input:** `لم يفعلون`
|
| 18 |
+
* **Sample Output (Bug):** `لم يفعلوَ` *(Destroyed the plural)*
|
| 19 |
+
* **The Solution:** Updated `src/nlp/grammar/grammar_rules.py` to explicitly protect the `وا` suffix (`if not word.endswith('وا')`), ensuring plural verbs safely bypass the singular truncation rule. **Result:** `لم يفعلوا` is now perfectly preserved.
|
| 20 |
+
|
| 21 |
+
## 3. Misspelled Alif Maqsura in Jazm Contexts
|
| 22 |
+
* **The Source Problem:** Many users mistakenly type a `ي` (Yaa) instead of `ى` (Alif Maqsura) at the end of verbs (typing `يسعي` instead of `يسعى`). When this typo entered a Jazm context (`لم`), the grammar rule saw the `ي`, truncated it, and wrongly applied a Kasra (`ِ`) instead of a Fatha (`َ`).
|
| 23 |
+
* **Sample Input:** `لم يسعي`
|
| 24 |
+
* **Sample Output (Bug):** `لم يسعِ`
|
| 25 |
+
* **The Solution:** We built a dynamic stem whitelist directly into the Jazm rules (`fatha_stems = {'يسع', 'يخش', 'ينس'...}`). Now, when the rule detects a known Alif Maqsura verb masquerading with a Yaa, it correctly forces a Fatha: **`لم يسعَ`**.
|
| 26 |
+
|
| 27 |
+
## 4. Singular Nasb Contexts Missing Fatha
|
| 28 |
+
* **The Source Problem:** Singular verbs ending in weak letters (`و`, `ي`) were not receiving their grammatically required explicit Fatha in Nasb contexts (`أن`, `لن`).
|
| 29 |
+
* **Sample Input:** `لن يدعو`
|
| 30 |
+
* **Sample Output (Bug):** `لن يدعو`
|
| 31 |
+
* **The Solution:** Added missing Nasb rules to proactively append the Fatha (`َ`) to verbs ending in `و` and `ي`. **Result:** `لن يدعوَ`.
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## 5. Removing the Obsolete IVtoOOV Filter
|
| 36 |
+
* **The Source Problem:** The pipeline previously had a rigid `IVtoOOV` (In-Vocabulary to Out-Of-Vocabulary) filter that heavily penalized structural grammar changes. Because of this, massive amounts of completely valid grammar fixes were being thrown away as False Negatives, blocking the Grammar model from doing its job.
|
| 37 |
+
* **The Solution:** We removed the obsolete `IVtoOOV` filter from the pipeline. This successfully unblocked the Grammar model, allowing valid structural changes to pass through, which directly doubled the Grammar dataset's recall!
|
| 38 |
+
|
| 39 |
+
## 6. Dual & Plural Grammar Agreements
|
| 40 |
+
* **The Source Problem:** The grammar rules lacked support for noun-adjective and demonstrative pronoun agreements for dual and plural forms.
|
| 41 |
+
* **The Solution:** Added missing demonstrative (`هذان/هاتان`) and noun-adjective dual/plural agreement rules to `grammar_rules.py`, and explicitly added bypass rules in `app.py` so these corrections wouldn't be erroneously blocked by spelling.
|
| 42 |
+
|
| 43 |
+
## 7. Pipeline Filter Reordering (Jaccard)
|
| 44 |
+
* **The Source Problem:** Valid grammar bypass rules were being evaluated *after* the strict Jaccard distance filter, meaning heavy structural changes were getting rejected before they could even be authorized by the bypass rules.
|
| 45 |
+
* **The Solution:** Reordered the `Jaccard` filter in `app.py` to correctly run *after* evaluating grammar bypass rules, ensuring authorized grammar corrections are properly verified.
|
| 46 |
+
|
| 47 |
+
## 8. Conditional Sentences Overcorrection
|
| 48 |
+
* **The Source Problem:** The grammar rule for conditional sentences was overcorrecting common words like `إن` (if) and `من` (who) when they were followed by non-verbs, incorrectly forcing verbs into Jazm.
|
| 49 |
+
* **The Solution:** Prevented the conditional sentences rule from triggering by strictly requiring that the subsequent word must be a verb.
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## 📈 Benchmark Achievements
|
| 54 |
+
Thanks to these highly targeted fixes, the Phase 10 benchmark results skyrocketed. We obliterated False Positives (down by 41!).
|
| 55 |
+
|
| 56 |
+
| Metric | Previous Run | **Current Run** | Difference |
|
| 57 |
+
|---|---|---|---|
|
| 58 |
+
| **Overall Pass Rate** | 56.2% | **74.38%** | 🚀 **+18.18%** |
|
| 59 |
+
| **Entities Pass Rate** | 13.3% | **63.33%** | 🟢 **+50.0%** |
|
| 60 |
+
| **Grammar Pass Rate** | 57.8% | **91.11%** | 🟢 **+33.3%** |
|
| 61 |
+
| **True Positives** | 95 | **112** | 🟢 **+17** |
|
| 62 |
+
| **False Positives** | 79 | **38** | 📉 **-41** *(Massive drop!)* |
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## 🚨 The Next Target: The "StageLocker" Bug
|
| 67 |
+
While we hit ~75%, the **Collision Dataset** is still suffering (only 32% pass rate).
|
| 68 |
+
**The Bug:** The pipeline utilizes a `StageLocker` inside `app.py`. When the Spelling model fixes a misspelled word, the StageLocker "locks" that word to protect it. However, if that exact word (or the context immediately around it) has a **Grammar error**, the Grammar model is completely blinded and blocked from touching it.
|
| 69 |
+
|
| 70 |
+
**Next Steps:** We must relax the StageLocker boundaries to allow the Grammar model to safely interact with words previously modified by Spelling!
|
src/nlp/punctuation/punctuation_rules.py
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
# PuncAra — Arabic Punctuation Restoration Rules
|
| 2 |
# Extracted from PuncAra.py — preprocessing + postprocessing + chunking logic.
|
| 3 |
# All classes are imported by punctuation_service.py.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
import re
|
| 6 |
import logging
|
|
@@ -64,7 +70,7 @@ def arabic_postprocessing(text: str) -> str:
|
|
| 64 |
|
| 65 |
|
| 66 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 67 |
-
# PUNCTUATION SAFETY LAYER — Pipeline Hardening v3.
|
| 68 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 69 |
|
| 70 |
ARABIC_PUNCT_CHARS = set('.,،؛؟!:;?!')
|
|
@@ -72,6 +78,10 @@ MAX_PUNCT_DELTA = 3
|
|
| 72 |
MAX_PUNCT_DELTA_SHORT = 1 # Stricter cap for short texts (≤2 words)
|
| 73 |
MAX_PUNCT_RATIO = 0.5 # max punctuation delta per word (multi-word diffs)
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
def _normalize_for_comparison(text: str) -> str:
|
| 77 |
"""
|
|
@@ -96,7 +106,8 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 96 |
ALLOWED:
|
| 97 |
- Inserting 1 punctuation mark (short text) or 1–3 (long text)
|
| 98 |
- Replacing one punctuation mark with another
|
| 99 |
-
- Adding terminal punctuation to
|
|
|
|
| 100 |
|
| 101 |
REJECTED:
|
| 102 |
- Adding/deleting/duplicating Arabic words
|
|
@@ -105,21 +116,27 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 105 |
- Punctuation spam: delta/word_count > 0.5 (multi-word diffs)
|
| 106 |
- Short text (≤2 words): delta > 1
|
| 107 |
- Any diff: delta > MAX_PUNCT_DELTA
|
| 108 |
-
- Adding terminal punctuation to short fragments (≤2 words) (FIX-01)
|
| 109 |
- Adding terminal punctuation when text already ends with punct
|
|
|
|
| 110 |
"""
|
| 111 |
original = diff.get('original', '')
|
| 112 |
correction = diff.get('correction', '')
|
| 113 |
|
| 114 |
-
# ── Rule 0 (FIX-01):
|
| 115 |
# PuncAra-v1 unconditionally adds . or ؟ to every sentence.
|
| 116 |
# This rule catches the pattern: "word" → "word." / "word؟" / "word،"
|
| 117 |
# where the ONLY change is appending 1-2 terminal punctuation marks.
|
| 118 |
#
|
| 119 |
-
#
|
| 120 |
-
#
|
| 121 |
-
#
|
| 122 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
TERMINAL_PUNCT = set('.,،؛؟!:;?!')
|
| 124 |
orig_stripped = original.rstrip()
|
| 125 |
corr_stripped = correction.rstrip()
|
|
@@ -139,33 +156,34 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 139 |
if _normalize_for_comparison(orig_no_punct.replace(' ', '')) == \
|
| 140 |
_normalize_for_comparison(corr_no_punct.replace(' ', '')):
|
| 141 |
# This is a pure terminal-punctuation addition.
|
| 142 |
-
#
|
|
|
|
| 143 |
_full_word_count = len(re.findall(
|
| 144 |
-
r'[\u0600-\u06FFa-zA-Z]+',
|
| 145 |
-
))
|
| 146 |
_full_already_has_terminal = bool(
|
| 147 |
re.search(r'[.،؛؟!?!][\s]*$', full_text)
|
| 148 |
) if full_text else False
|
| 149 |
-
# Also check for ellipsis (... at end)
|
| 150 |
_full_has_ellipsis = full_text.rstrip().endswith('...') if full_text else False
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
#
|
| 155 |
-
#
|
|
|
|
|
|
|
|
|
|
| 156 |
_added_punct = correction[len(orig_stripped):]
|
| 157 |
-
if '!' in _added_punct or '؟' in _added_punct:
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
'كيف', 'لماذا', 'ماذا', 'أي', 'لعل', 'ليت'}
|
| 161 |
-
_has_cue = any(w in _EXCL_CUES for w in full_text.split())
|
| 162 |
if not _has_cue:
|
| 163 |
logger.info(
|
| 164 |
-
f"[PUNC-SAFETY] Blocked !/
|
| 165 |
f"'{original}' → '{correction}'"
|
| 166 |
)
|
| 167 |
return False
|
| 168 |
-
|
| 169 |
logger.info(
|
| 170 |
f"[PUNC-SAFETY] Allowed terminal punct for sentence "
|
| 171 |
f"({_full_word_count} words): "
|
|
@@ -173,7 +191,7 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 173 |
)
|
| 174 |
# Fall through to remaining rules (don't return yet)
|
| 175 |
else:
|
| 176 |
-
#
|
| 177 |
logger.info(
|
| 178 |
f"[PUNC-SAFETY] TerminalPunctuationGuard triggered: removing trailing punctuation "
|
| 179 |
f"'{original}' → '{correction}'"
|
|
@@ -202,9 +220,11 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 202 |
return False
|
| 203 |
|
| 204 |
# ── Rule 0c (Batch 4 + FIX-26): Reject punctuation rearrangement/substitution ──
|
| 205 |
-
# When original already has punctuation and the correction merely MOVES
|
| 206 |
-
# or
|
| 207 |
-
# The PuncAra model should NOT replace existing punctuation
|
|
|
|
|
|
|
| 208 |
orig_punct_count_r0c = sum(1 for c in original if c in ARABIC_PUNCT_CHARS)
|
| 209 |
corr_punct_count_r0c = sum(1 for c in correction if c in ARABIC_PUNCT_CHARS)
|
| 210 |
if orig_punct_count_r0c > 0 and corr_punct_count_r0c > 0:
|
|
@@ -212,9 +232,10 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 212 |
orig_alpha_r0c = re.sub(r'[.,،؛؟!:;?\s]', '', original)
|
| 213 |
corr_alpha_r0c = re.sub(r'[.,،؛؟!:;?\s]', '', correction)
|
| 214 |
if _normalize_for_comparison(orig_alpha_r0c) == _normalize_for_comparison(corr_alpha_r0c):
|
| 215 |
-
# Same word content, but punct changed — reject any punct modification
|
|
|
|
| 216 |
logger.info(
|
| 217 |
-
f"[PUNC-SAFETY] Rejected punct substitution: "
|
| 218 |
f"'{original}' → '{correction}'"
|
| 219 |
)
|
| 220 |
return False
|
|
@@ -249,4 +270,3 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 249 |
return False
|
| 250 |
|
| 251 |
return True
|
| 252 |
-
|
|
|
|
| 1 |
# PuncAra — Arabic Punctuation Restoration Rules
|
| 2 |
# Extracted from PuncAra.py — preprocessing + postprocessing + chunking logic.
|
| 3 |
# All classes are imported by punctuation_service.py.
|
| 4 |
+
#
|
| 5 |
+
# MERGED: Best of V1 + V2
|
| 6 |
+
# - V2: Threshold >= 1 (not 5) — allows terminal punct on any real text
|
| 7 |
+
# - V2: Fallback to `original` word count when `full_text` is empty
|
| 8 |
+
# - V1: Softened exclamation guard — blocks ؟/! on SHORT texts (< 3 words)
|
| 9 |
+
# without cue words, but allows on longer sentences
|
| 10 |
|
| 11 |
import re
|
| 12 |
import logging
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 73 |
+
# PUNCTUATION SAFETY LAYER — Pipeline Hardening v3.4 (Merged V1+V2)
|
| 74 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 75 |
|
| 76 |
ARABIC_PUNCT_CHARS = set('.,،؛؟!:;?!')
|
|
|
|
| 78 |
MAX_PUNCT_DELTA_SHORT = 1 # Stricter cap for short texts (≤2 words)
|
| 79 |
MAX_PUNCT_RATIO = 0.5 # max punctuation delta per word (multi-word diffs)
|
| 80 |
|
| 81 |
+
# Exclamation/question cue words (from V1 FIX-29, used in softened guard)
|
| 82 |
+
_EXCL_CUES = {'يا', 'ما', 'كم', 'لا', 'هل', 'أين', 'متى',
|
| 83 |
+
'كيف', 'لماذا', 'ماذا', 'أي', 'لعل', 'ليت'}
|
| 84 |
+
|
| 85 |
|
| 86 |
def _normalize_for_comparison(text: str) -> str:
|
| 87 |
"""
|
|
|
|
| 106 |
ALLOWED:
|
| 107 |
- Inserting 1 punctuation mark (short text) or 1–3 (long text)
|
| 108 |
- Replacing one punctuation mark with another
|
| 109 |
+
- Adding terminal punctuation to any text (1+ words) that lacks it
|
| 110 |
+
- Adding ؟/! to short texts (< 3 words) ONLY with cue words
|
| 111 |
|
| 112 |
REJECTED:
|
| 113 |
- Adding/deleting/duplicating Arabic words
|
|
|
|
| 116 |
- Punctuation spam: delta/word_count > 0.5 (multi-word diffs)
|
| 117 |
- Short text (≤2 words): delta > 1
|
| 118 |
- Any diff: delta > MAX_PUNCT_DELTA
|
|
|
|
| 119 |
- Adding terminal punctuation when text already ends with punct
|
| 120 |
+
- Adding ؟/! to short texts without interrogative/exclamatory cues
|
| 121 |
"""
|
| 122 |
original = diff.get('original', '')
|
| 123 |
correction = diff.get('correction', '')
|
| 124 |
|
| 125 |
+
# ── Rule 0 (FIX-01 + FIX-30 + Merged Guard): Terminal punctuation ──
|
| 126 |
# PuncAra-v1 unconditionally adds . or ؟ to every sentence.
|
| 127 |
# This rule catches the pattern: "word" → "word." / "word؟" / "word،"
|
| 128 |
# where the ONLY change is appending 1-2 terminal punctuation marks.
|
| 129 |
#
|
| 130 |
+
# From V2 (FIX-30): Threshold lowered from 5 → 1. Even single-word
|
| 131 |
+
# fragments deserve terminal punctuation (e.g. "اليوم" → "اليوم.").
|
| 132 |
+
#
|
| 133 |
+
# From V2 (FIX-30): When full_text isn't provided, fall back to
|
| 134 |
+
# counting words in `original` instead of returning 0.
|
| 135 |
+
#
|
| 136 |
+
# From V1 (FIX-29, softened): For SHORT texts (< 3 words), block ؟/!
|
| 137 |
+
# unless text contains interrogative/exclamatory cue words. For longer
|
| 138 |
+
# texts (3+ words), allow any terminal punct freely. This prevents
|
| 139 |
+
# "محمد" → "محمد؟" while still allowing "اليوم" → "اليوم.".
|
| 140 |
TERMINAL_PUNCT = set('.,،؛؟!:;?!')
|
| 141 |
orig_stripped = original.rstrip()
|
| 142 |
corr_stripped = correction.rstrip()
|
|
|
|
| 156 |
if _normalize_for_comparison(orig_no_punct.replace(' ', '')) == \
|
| 157 |
_normalize_for_comparison(corr_no_punct.replace(' ', '')):
|
| 158 |
# This is a pure terminal-punctuation addition.
|
| 159 |
+
# V2 FIX-30: Fall back to original when full_text is empty
|
| 160 |
+
_word_count_source = full_text if full_text else original
|
| 161 |
_full_word_count = len(re.findall(
|
| 162 |
+
r'[\u0600-\u06FFa-zA-Z]+', _word_count_source
|
| 163 |
+
))
|
| 164 |
_full_already_has_terminal = bool(
|
| 165 |
re.search(r'[.،؛؟!?!][\s]*$', full_text)
|
| 166 |
) if full_text else False
|
|
|
|
| 167 |
_full_has_ellipsis = full_text.rstrip().endswith('...') if full_text else False
|
| 168 |
|
| 169 |
+
# V2 FIX-30: Allow for 1+ words (not 5)
|
| 170 |
+
if _full_word_count >= 1 and not _full_already_has_terminal and not _full_has_ellipsis:
|
| 171 |
+
# ── Softened FIX-29 (Merged): Short-text ؟/! guard ──
|
| 172 |
+
# For short texts (< 3 words), block ؟ and ! unless
|
| 173 |
+
# cue words are present. Prevents "محمد" → "محمد؟"
|
| 174 |
+
# but allows "اليوم" → "اليوم." (period is safe).
|
| 175 |
+
# For 3+ words, allow freely (V2 behavior).
|
| 176 |
_added_punct = correction[len(orig_stripped):]
|
| 177 |
+
if _full_word_count < 3 and ('!' in _added_punct or '؟' in _added_punct):
|
| 178 |
+
_text_to_scan = full_text if full_text else original
|
| 179 |
+
_has_cue = any(w in _EXCL_CUES for w in _text_to_scan.split())
|
|
|
|
|
|
|
| 180 |
if not _has_cue:
|
| 181 |
logger.info(
|
| 182 |
+
f"[PUNC-SAFETY] Blocked !/؟ on short text without cue: "
|
| 183 |
f"'{original}' → '{correction}'"
|
| 184 |
)
|
| 185 |
return False
|
| 186 |
+
|
| 187 |
logger.info(
|
| 188 |
f"[PUNC-SAFETY] Allowed terminal punct for sentence "
|
| 189 |
f"({_full_word_count} words): "
|
|
|
|
| 191 |
)
|
| 192 |
# Fall through to remaining rules (don't return yet)
|
| 193 |
else:
|
| 194 |
+
# Already has terminal punct or ends in ellipsis → REJECT
|
| 195 |
logger.info(
|
| 196 |
f"[PUNC-SAFETY] TerminalPunctuationGuard triggered: removing trailing punctuation "
|
| 197 |
f"'{original}' → '{correction}'"
|
|
|
|
| 220 |
return False
|
| 221 |
|
| 222 |
# ── Rule 0c (Batch 4 + FIX-26): Reject punctuation rearrangement/substitution ──
|
| 223 |
+
# When original already has punctuation and the correction merely MOVES,
|
| 224 |
+
# SUBSTITUTES, or STACKS marks (e.g., ، → : or ، → ؛ or ؟ → ؟!), reject.
|
| 225 |
+
# The PuncAra model should NOT replace or pile onto existing punctuation —
|
| 226 |
+
# a sentence that already ends with punctuation must never get a second
|
| 227 |
+
# mark added next to it.
|
| 228 |
orig_punct_count_r0c = sum(1 for c in original if c in ARABIC_PUNCT_CHARS)
|
| 229 |
corr_punct_count_r0c = sum(1 for c in correction if c in ARABIC_PUNCT_CHARS)
|
| 230 |
if orig_punct_count_r0c > 0 and corr_punct_count_r0c > 0:
|
|
|
|
| 232 |
orig_alpha_r0c = re.sub(r'[.,،؛؟!:;?\s]', '', original)
|
| 233 |
corr_alpha_r0c = re.sub(r'[.,،؛؟!:;?\s]', '', correction)
|
| 234 |
if _normalize_for_comparison(orig_alpha_r0c) == _normalize_for_comparison(corr_alpha_r0c):
|
| 235 |
+
# Same word content, but punct changed — reject any punct modification,
|
| 236 |
+
# whether it's a substitution or an addition on top of existing punct.
|
| 237 |
logger.info(
|
| 238 |
+
f"[PUNC-SAFETY] Rejected punct substitution/stacking: "
|
| 239 |
f"'{original}' → '{correction}'"
|
| 240 |
)
|
| 241 |
return False
|
|
|
|
| 270 |
return False
|
| 271 |
|
| 272 |
return True
|
|
|