Commit ·
e7b2a51
1
Parent(s): 41ea30b
Fix structural regressions
Browse files- diff.txt +296 -0
- diff_gram.txt +311 -0
- diff_punc.txt +46 -0
- diff_spell.txt +264 -0
- extension/content-inline.css +72 -52
- extension/content-inline.js +22 -18
- old2.py +1663 -0
- old_gram2.py +940 -0
- old_rules.py +1606 -0
- src/nlp/grammar/grammar_rules.py +23 -4
- src/nlp/punctuation/punctuation_rules.py +25 -0
- src/nlp/spelling/araspell_rules.py +7 -0
- tests/phase10/reports/phase10_results.json +0 -0
diff.txt
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
commit cf83a1acd06e1a347cae033a7cb9fdeff8dfcd01
|
| 2 |
+
Author: YoussefReda9 <youssefreda9004@gmail.com>
|
| 3 |
+
Date: Tue Jun 30 04:36:40 2026 +0300
|
| 4 |
+
|
| 5 |
+
Fix: 10 critical NLP logic bugs in grammar, spelling, and punctuation to prevent false positives
|
| 6 |
+
|
| 7 |
+
diff --git a/src/nlp/spelling/araspell_rules.py b/src/nlp/spelling/araspell_rules.py
|
| 8 |
+
index 634f134..0102cae 100644
|
| 9 |
+
--- a/src/nlp/spelling/araspell_rules.py
|
| 10 |
+
+++ b/src/nlp/spelling/araspell_rules.py
|
| 11 |
+
@@ -129,14 +129,9 @@ class AraSpellPostProcessor:
|
| 12 |
+
@staticmethod
|
| 13 |
+
def remove_duplicate_words(text: str) -> str:
|
| 14 |
+
"""Remove consecutive duplicate words. e.g. كتاب كتاب → كتاب"""
|
| 15 |
+
- words = text.split()
|
| 16 |
+
- if len(words) < 2:
|
| 17 |
+
- return text
|
| 18 |
+
- result = [words[0]]
|
| 19 |
+
- for i in range(1, len(words)):
|
| 20 |
+
- if words[i] != words[i-1]:
|
| 21 |
+
- result.append(words[i])
|
| 22 |
+
- return ' '.join(result)
|
| 23 |
+
+ # Bug 2.11: Destroys rhetorical repetition (التوكيد اللفظي) like "صفا صفا".
|
| 24 |
+
+ # Disabled as it destroys valid Arabic phrases.
|
| 25 |
+
+ return text
|
| 26 |
+
|
| 27 |
+
@staticmethod
|
| 28 |
+
def normalize_spaces(text: str) -> str:
|
| 29 |
+
@@ -337,11 +332,11 @@ class AraSpellPostProcessor:
|
| 30 |
+
if any(word.endswith(e) for e in PROTECTED_ENDINGS):
|
| 31 |
+
result.append(word)
|
| 32 |
+
continue
|
| 33 |
+
- if word in PROTECTED_HA_WORDS:
|
| 34 |
+
+ if word in PROTECTED_HA_WORDS or word in ['هذه', 'هاته']:
|
| 35 |
+
result.append(word)
|
| 36 |
+
continue
|
| 37 |
+
if len(word) >= 3 and word.endswith('ه'):
|
| 38 |
+
- if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS:
|
| 39 |
+
+ if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS or word[-2] in 'اويءؤئ':
|
| 40 |
+
candidate_with_ta = word[:-1] + 'ة'
|
| 41 |
+
# Default: prefer ة (correct Arabic orthography for feminine nouns)
|
| 42 |
+
if vocab_manager:
|
| 43 |
+
@@ -389,11 +384,8 @@ class AraSpellPostProcessor:
|
| 44 |
+
if i + 1 < len(words):
|
| 45 |
+
next_word = words[i + 1]
|
| 46 |
+
# Bug 2.11: Destroys Badal structures (الأستاذ أستاذ -> الأستاذ)
|
| 47 |
+
- if word == next_word: # Only remove exact duplicates, not normalized duplicates
|
| 48 |
+
- keep = next_word if next_word.startswith('ال') and not word.startswith('ال') else word
|
| 49 |
+
- result.append(keep)
|
| 50 |
+
- i += 2
|
| 51 |
+
- continue
|
| 52 |
+
+ # and Rhetorical Repetition (التوكيد اللفظي)
|
| 53 |
+
+ # Removed the aggressive duplicate word deletion.
|
| 54 |
+
result.append(word)
|
| 55 |
+
i += 1
|
| 56 |
+
return ' '.join(result)
|
| 57 |
+
@@ -1177,7 +1169,15 @@ class ArabicSpellChecker:
|
| 58 |
+
logger.info("[MLM/CONTEXTUAL] Disabled by configuration (use_contextual=False)")
|
| 59 |
+
|
| 60 |
+
def _fix_repeated_end_chars(self, text: str) -> str:
|
| 61 |
+
- text = re.sub(r'([ا-ي])\1+\b', r'\1', text)
|
| 62 |
+
+ # Exclude 'ي' if it is preceded by a Kasra or another Yaa (e.g., يحيي)
|
| 63 |
+
+ def _replace_repeated(m):
|
| 64 |
+
+ w = m.group(0)
|
| 65 |
+
+ char = m.group(2)
|
| 66 |
+
+ if w.endswith('يي'):
|
| 67 |
+
+ if self.vocab_manager and self.vocab_manager.is_iv(w):
|
| 68 |
+
+ return w
|
| 69 |
+
+ return m.group(1) + char
|
| 70 |
+
+ text = re.sub(r'\b([^\s]+?)([\u0621-\u064A])\2+\b', _replace_repeated, text)
|
| 71 |
+
return text
|
| 72 |
+
|
| 73 |
+
def _fix_merged_with_errors(self, text: str) -> str:
|
| 74 |
+
|
| 75 |
+
commit ee5e50414a8f53b71d5fa3d4f864c812ee835567
|
| 76 |
+
Author: YoussefReda9 <youssefreda9004@gmail.com>
|
| 77 |
+
Date: Tue Jun 30 04:11:41 2026 +0300
|
| 78 |
+
|
| 79 |
+
Fix 30 NLP edge cases in Grammar, Spelling, and Punctuation (Phase 10 results and Extension UI improvements)
|
| 80 |
+
|
| 81 |
+
diff --git a/src/nlp/spelling/araspell_rules.py b/src/nlp/spelling/araspell_rules.py
|
| 82 |
+
index 39d02b7..634f134 100644
|
| 83 |
+
--- a/src/nlp/spelling/araspell_rules.py
|
| 84 |
+
+++ b/src/nlp/spelling/araspell_rules.py
|
| 85 |
+
@@ -154,17 +154,9 @@ class AraSpellPostProcessor:
|
| 86 |
+
@staticmethod
|
| 87 |
+
def remove_word_repetition_with_wa(text: str) -> str:
|
| 88 |
+
"""Remove word و word → word"""
|
| 89 |
+
- words = text.split()
|
| 90 |
+
- result = []
|
| 91 |
+
- i = 0
|
| 92 |
+
- while i < len(words):
|
| 93 |
+
- if i + 2 < len(words) and words[i] == words[i+2] and words[i+1] == 'و':
|
| 94 |
+
- result.append(words[i])
|
| 95 |
+
- i += 3
|
| 96 |
+
- else:
|
| 97 |
+
- result.append(words[i])
|
| 98 |
+
- i += 1
|
| 99 |
+
- return ' '.join(result)
|
| 100 |
+
+ # Bug 2.9: This deletes valid rhetorical repetition (التوكيد اللفظي) like "صنفا وصنفا"
|
| 101 |
+
+ # Disabled as it is highly destructive to valid Arabic.
|
| 102 |
+
+ return text
|
| 103 |
+
|
| 104 |
+
# --- Hamza & Ta Marbuta Handling ---
|
| 105 |
+
|
| 106 |
+
@@ -181,7 +173,7 @@ class AraSpellPostProcessor:
|
| 107 |
+
'اذا': 'إذا', 'اذ': 'إذ',
|
| 108 |
+
'اي': 'أي', 'اين': 'أين',
|
| 109 |
+
'او': 'أو',
|
| 110 |
+
- 'اما': 'أما',
|
| 111 |
+
+
|
| 112 |
+
'ان': 'أن', 'انه': 'أنه', 'انها': 'أنها', 'انهم': 'أنهم',
|
| 113 |
+
'اخر': 'آخر', 'اخرى': 'أخرى',
|
| 114 |
+
'الان': 'الآن',
|
| 115 |
+
@@ -201,9 +193,9 @@ class AraSpellPostProcessor:
|
| 116 |
+
'اهل': 'أهل',
|
| 117 |
+
'اطفال': 'أطفال',
|
| 118 |
+
'اصدقاء': 'أصدقاء', 'اصدقائي': 'أصدقائي',
|
| 119 |
+
- 'اعتقد': 'أعتقد', 'اريد': 'أريد', 'احب': 'أحب',
|
| 120 |
+
- 'اعرف': 'أعرف', 'اعلم': 'أعلم',
|
| 121 |
+
- 'اخذ': 'أخذ', 'اكل': 'أكل',
|
| 122 |
+
+ 'اريد': 'أريد', 'احب': 'أحب',
|
| 123 |
+
+ 'اعلم': 'أعلم',
|
| 124 |
+
+ 'اكل': 'أكل',
|
| 125 |
+
'الايام': 'الأيام',
|
| 126 |
+
'الاطفال': 'الأطفال',
|
| 127 |
+
'الاسعار': 'الأسعار',
|
| 128 |
+
@@ -243,10 +235,8 @@ class AraSpellPostProcessor:
|
| 129 |
+
'ادارة': 'إدارة', 'ادارية': 'إدارية',
|
| 130 |
+
'اعلام': 'إعلام', 'اعلامي': 'إعلامي',
|
| 131 |
+
'احتمال': 'احتمال', 'احتفال': 'احتفال',
|
| 132 |
+
- 'ازور': 'أزور', 'اذهب': 'أذهب', 'اكتب': 'أكتب',
|
| 133 |
+
'اقرا': 'أقرأ', 'اقرأ': 'أقرأ',
|
| 134 |
+
- 'اعمل': 'أعمل', 'ادرس': 'أدرس',
|
| 135 |
+
- 'اشتري': 'أشتري', 'اسافر': 'أسافر',
|
| 136 |
+
+ 'اسافر': 'أسافر',
|
| 137 |
+
'احبه': 'أحبه',
|
| 138 |
+
'مسؤول': 'مسؤول', 'مسؤولية': 'مسؤولية',
|
| 139 |
+
'رؤية': 'رؤية', 'رؤيا': 'رؤيا',
|
| 140 |
+
@@ -259,7 +249,7 @@ class AraSpellPostProcessor:
|
| 141 |
+
'مصطفي': 'مصطفى', 'موسي': 'موسى', 'عيسي': 'عيسى',
|
| 142 |
+
'هدي': 'هدى', 'بني': 'بنى',
|
| 143 |
+
'معني': 'معنى', 'مبني': 'مبنى',
|
| 144 |
+
- 'علي': 'على', # Common alif maqsura confusion
|
| 145 |
+
+
|
| 146 |
+
'الي': 'إلى',
|
| 147 |
+
# FIX-47: Verb+pronoun hamza entries (احبه→أحبه)
|
| 148 |
+
'احبه': 'أحبه', 'احبها': 'أحبها', 'احبك': 'أحبك',
|
| 149 |
+
@@ -280,16 +270,9 @@ class AraSpellPostProcessor:
|
| 150 |
+
@staticmethod
|
| 151 |
+
def fix_hamza_conservative(text: str) -> str:
|
| 152 |
+
"""Conservative Hamza normalization — only at word END, not middle."""
|
| 153 |
+
- words = text.split()
|
| 154 |
+
- result = []
|
| 155 |
+
- for word in words:
|
| 156 |
+
- if len(word) >= 3:
|
| 157 |
+
- if word.endswith('أ'):
|
| 158 |
+
- word = word[:-1] + 'ا'
|
| 159 |
+
- if word.endswith('إ'):
|
| 160 |
+
- word = word[:-1] + 'ا'
|
| 161 |
+
- result.append(word)
|
| 162 |
+
- return ' '.join(result)
|
| 163 |
+
+ # Bug 2.5: Blindly changing أ at the end of word to ا corrupts valid orthography (قرأ -> قرا)
|
| 164 |
+
+ # Disabled as it is highly destructive.
|
| 165 |
+
+ return text
|
| 166 |
+
|
| 167 |
+
# Attached prefixes that can precede hamza-whitelist words
|
| 168 |
+
# Ordered longest-first so وال is tried before و
|
| 169 |
+
@@ -364,8 +347,12 @@ class AraSpellPostProcessor:
|
| 170 |
+
if vocab_manager:
|
| 171 |
+
ta_iv = vocab_manager.is_iv(candidate_with_ta)
|
| 172 |
+
ha_iv = vocab_manager.is_iv(word)
|
| 173 |
+
- if ta_iv:
|
| 174 |
+
- # Always prefer ة when it's a valid word
|
| 175 |
+
+ if ha_iv and ta_iv:
|
| 176 |
+
+ # Bug 2.2: Do not prefer ة if ه is also valid (possessive pronoun)
|
| 177 |
+
+ result.append(word)
|
| 178 |
+
+ continue
|
| 179 |
+
+ elif ta_iv:
|
| 180 |
+
+ # Prefer ة when ONLY the ة form is valid
|
| 181 |
+
result.append(candidate_with_ta)
|
| 182 |
+
continue
|
| 183 |
+
elif ha_iv:
|
| 184 |
+
@@ -401,7 +388,8 @@ class AraSpellPostProcessor:
|
| 185 |
+
word = word[:-1]
|
| 186 |
+
if i + 1 < len(words):
|
| 187 |
+
next_word = words[i + 1]
|
| 188 |
+
- if normalize_word(word) == normalize_word(next_word):
|
| 189 |
+
+ # Bug 2.11: Destroys Badal structures (الأستاذ أستاذ -> الأستاذ)
|
| 190 |
+
+ if word == next_word: # Only remove exact duplicates, not normalized duplicates
|
| 191 |
+
keep = next_word if next_word.startswith('ال') and not word.startswith('ال') else word
|
| 192 |
+
result.append(keep)
|
| 193 |
+
i += 2
|
| 194 |
+
@@ -454,18 +442,8 @@ class AraSpellPostProcessor:
|
| 195 |
+
result.append(word + next_word)
|
| 196 |
+
i += 2
|
| 197 |
+
continue
|
| 198 |
+
- if len(word) >= 2 and len(next_word) >= 2 and word[-1] == next_word[0]:
|
| 199 |
+
- if not (word in STANDALONE_WORDS and next_word in STANDALONE_WORDS):
|
| 200 |
+
- result.append(word[:-1] + next_word)
|
| 201 |
+
- i += 2
|
| 202 |
+
- continue
|
| 203 |
+
- if (2 <= len(word) <= 4 and
|
| 204 |
+
- 1 <= len(next_word) <= 2 and
|
| 205 |
+
- 3 <= len(word) + len(next_word) <= 7):
|
| 206 |
+
- if not (word in STANDALONE_WORDS and next_word in STANDALONE_WORDS):
|
| 207 |
+
- result.append(word + next_word)
|
| 208 |
+
- i += 2
|
| 209 |
+
- continue
|
| 210 |
+
+ # Bug 2.3: Destructive word merging (يوم مشمس -> يومشمس)
|
| 211 |
+
+ # Removed generic boundary letter merging.
|
| 212 |
+
result.append(word)
|
| 213 |
+
i += 1
|
| 214 |
+
return ' '.join(result)
|
| 215 |
+
@@ -779,15 +757,7 @@ class WordAligner:
|
| 216 |
+
if in_iv and not out_iv:
|
| 217 |
+
return input_word
|
| 218 |
+
if in_iv and out_iv:
|
| 219 |
+
- # Fix S1: When only difference is ه→ة at word end, prefer ة
|
| 220 |
+
- # (correct Arabic orthography — ة is the standard feminine ending)
|
| 221 |
+
- if (input_word.endswith('ه') and output_word.endswith('ة')
|
| 222 |
+
- and input_word[:-1] == output_word[:-1]):
|
| 223 |
+
- return output_word
|
| 224 |
+
- # Fix S1: Also handle ة→ه (don't regress a correct ة to ه)
|
| 225 |
+
- if (input_word.endswith('ة') and output_word.endswith('ه')
|
| 226 |
+
- and input_word[:-1] == output_word[:-1]):
|
| 227 |
+
- return input_word
|
| 228 |
+
+ # Bug 2.2: Do not prefer ة over ه if both are IV, because ه is often a valid possessive pronoun.
|
| 229 |
+
return input_word
|
| 230 |
+
if len(input_word) == len(output_word) and len(input_word) >= 3:
|
| 231 |
+
for i in range(len(input_word)):
|
| 232 |
+
@@ -1211,51 +1181,23 @@ class ArabicSpellChecker:
|
| 233 |
+
return text
|
| 234 |
+
|
| 235 |
+
def _fix_merged_with_errors(self, text: str) -> str:
|
| 236 |
+
- text = re.sub(r'ال([ا-ي])\1+([ا-ي]{2,})', r'ال\2', text)
|
| 237 |
+
+ # Bug 2.10: This regex was r'ال\2', deleting all instances of the character
|
| 238 |
+
+ text = re.sub(r'ال([ا-ي])\1+([ا-ي]{2,})', r'ال\1\2', text)
|
| 239 |
+
text = re.sub(r'\b([ا-ي]{3,})([ا-ي])\2+\b', r'\1\2', text)
|
| 240 |
+
return text
|
| 241 |
+
|
| 242 |
+
def _split_merged_words_linguistic(self, text: str) -> str:
|
| 243 |
+
- text = re.sub(
|
| 244 |
+
- r'\b(في|من|إلى|الى|حتى|منذ|خلال|بعد|قبل)(ال)?([ا-ي]{3,})',
|
| 245 |
+
- r'\1 \2\3', text
|
| 246 |
+
- )
|
| 247 |
+
- text = re.sub(r'\b(كل)([ا-ي]{3,})', r'\1 \2', text)
|
| 248 |
+
- text = re.sub(r'([ا-ي]{3,})(ال)([ا-ي]{3,})', r'\1 \2\3', text)
|
| 249 |
+
- text = re.sub(r'\b([بلك])(ال)?([ا-ي]{3,})', r'\1 \2\3', text)
|
| 250 |
+
- text = re.sub(r'([ا-ي]{4,})(عليكم|عليك|عليه|عليها)', r'\1 \2', text)
|
| 251 |
+
- text = re.sub(r'([ا-ي]{3,})(على|عن)([ا-ي]{3,})', r'\1 \2 \3', text)
|
| 252 |
+
+ # Bug 2.7: Catastrophic preposition splitting (e.g. منطق -> من طق)
|
| 253 |
+
+ # Disabled generic regex splitting as it is highly destructive to valid vocabulary.
|
| 254 |
+
return text
|
| 255 |
+
|
| 256 |
+
def _split_long_words_heuristic(self, text: str, max_length: int = 15) -> str:
|
| 257 |
+
- words = text.split()
|
| 258 |
+
- result = []
|
| 259 |
+
- for word in words:
|
| 260 |
+
- if len(word) <= max_length:
|
| 261 |
+
- result.append(word)
|
| 262 |
+
- continue
|
| 263 |
+
- if 'ال' in word[2:]:
|
| 264 |
+
- parts = word.split('ال', 1)
|
| 265 |
+
- if len(parts[0]) >= 2 and len(parts[1]) >= 3:
|
| 266 |
+
- result.extend([parts[0], 'ال' + parts[1]])
|
| 267 |
+
- continue
|
| 268 |
+
- if len(word) >= 8:
|
| 269 |
+
- split_found = False
|
| 270 |
+
- for split_pos in [2, 3]:
|
| 271 |
+
- prefix = word[:split_pos]
|
| 272 |
+
- suffix = word[split_pos:]
|
| 273 |
+
- if prefix in ['في', 'من', 'على', 'عن', 'مع', 'كل', 'ب', 'ل', 'ك']:
|
| 274 |
+
- result.extend([prefix, suffix])
|
| 275 |
+
- split_found = True
|
| 276 |
+
- break
|
| 277 |
+
- if not split_found:
|
| 278 |
+
- result.append(word)
|
| 279 |
+
- else:
|
| 280 |
+
- result.append(word)
|
| 281 |
+
- return ' '.join(result)
|
| 282 |
+
+ # Bug 2.8: Overzealous long word splitting (e.g. فيتامينات -> في تامينات)
|
| 283 |
+
+ # Disabled as it creates more errors than it fixes.
|
| 284 |
+
+ return text
|
| 285 |
+
|
| 286 |
+
def _normalize_tanween_patterns(self, text: str) -> str:
|
| 287 |
+
- text = re.sub(r'([ا-ي]{2,})أ\b', r'\1اً', text)
|
| 288 |
+
+ # Bug 2.6: Blind replacement of trailing أ with اً corrupts verbs and nominative cases (قرأ -> قراً)
|
| 289 |
+
text = re.sub(r'\s+أ\s+', ' ', text)
|
| 290 |
+
text = re.sub(r'\b([بلك])\s+([ا-ي])', r'\1\2', text)
|
| 291 |
+
return text
|
| 292 |
+
@@ -1661,3 +1603,4 @@ class ArabicSpellChecker:
|
| 293 |
+
result = ' '.join(res_words_list)
|
| 294 |
+
|
| 295 |
+
return result
|
| 296 |
+
+
|
diff_gram.txt
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/src/nlp/grammar/grammar_rules.py b/src/nlp/grammar/grammar_rules.py
|
| 2 |
+
index 141fb50..1f80154 100644
|
| 3 |
+
--- a/src/nlp/grammar/grammar_rules.py
|
| 4 |
+
+++ b/src/nlp/grammar/grammar_rules.py
|
| 5 |
+
@@ -77,31 +77,8 @@ class ArabicGrammarGuard:
|
| 6 |
+
return " ".join(corrected_tokens)
|
| 7 |
+
|
| 8 |
+
def smart_asmaa_khamsa_fix(self, text):
|
| 9 |
+
- tokens = simple_word_tokenize(text)
|
| 10 |
+
- disambig_tokens = self.mle.disambiguate(tokens)
|
| 11 |
+
- corrected_tokens = []
|
| 12 |
+
- verb_seen = False
|
| 13 |
+
-
|
| 14 |
+
- for i, token_info in enumerate(disambig_tokens):
|
| 15 |
+
- word = tokens[i]
|
| 16 |
+
-
|
| 17 |
+
- pos_tag = token_info.analyses[0].analysis.get('pos', 'unknown') if token_info.analyses else 'unknown'
|
| 18 |
+
-
|
| 19 |
+
- if pos_tag == 'verb':
|
| 20 |
+
- verb_seen = True
|
| 21 |
+
- corrected_tokens.append(word)
|
| 22 |
+
- continue
|
| 23 |
+
-
|
| 24 |
+
- is_asmaa = any(word.startswith(root) or word.startswith('أ' + root[1:]) for root in self.asmaa_khamsa_roots if len(root)>1)
|
| 25 |
+
-
|
| 26 |
+
- if is_asmaa and len(word) >= 3:
|
| 27 |
+
- if verb_seen:
|
| 28 |
+
- word = word.replace('ا', 'و').replace('ي', 'و')
|
| 29 |
+
- verb_seen = False
|
| 30 |
+
-
|
| 31 |
+
- corrected_tokens.append(word)
|
| 32 |
+
-
|
| 33 |
+
- return " ".join(corrected_tokens)
|
| 34 |
+
+ # Bug 1.1: Disabled blind replacement of ا and ي with و as it corrupts object position
|
| 35 |
+
+ return text
|
| 36 |
+
|
| 37 |
+
def _apply_jazm_to_verb(self, word, token_info):
|
| 38 |
+
# 1. Handle Af'al Khamsa using camel_tools analysis
|
| 39 |
+
@@ -182,7 +159,7 @@ class ArabicGrammarGuard:
|
| 40 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 41 |
+
|
| 42 |
+
nasb_particles = ['أن', 'ان', 'لن', 'كي', 'لكي', 'حتى', 'حتي', 'إذن', 'اذا']
|
| 43 |
+
- jazm_particles = ['لم', 'لما', 'لا']
|
| 44 |
+
+ jazm_particles = ['لم', 'لما']
|
| 45 |
+
|
| 46 |
+
corrected_tokens = []
|
| 47 |
+
|
| 48 |
+
@@ -196,12 +173,16 @@ class ArabicGrammarGuard:
|
| 49 |
+
|
| 50 |
+
if i > 0:
|
| 51 |
+
prev_word = tokens[i-1]
|
| 52 |
+
- if prev_word in nasb_particles or word.startswith('ل'):
|
| 53 |
+
+ if prev_word in nasb_particles:
|
| 54 |
+
is_nasb_context = True
|
| 55 |
+
- if prev_word in jazm_particles or word.startswith('ل') or word.startswith('ول'):
|
| 56 |
+
+ if prev_word in jazm_particles:
|
| 57 |
+
is_jazm_context = True
|
| 58 |
+
+ # Lam Al-Ta'leel / Lam Al-Amr logic was flawed (triggering for ANY word starting with ل)
|
| 59 |
+
+ # Ensure it only applies to actual verbs starting with 'لي' or 'لت' etc.
|
| 60 |
+
+ if (word.startswith('ل') or word.startswith('ول') or word.startswith('فل')) and len(word) >= 4 and word[1] in ['ي', 'ت', 'ن', 'أ']:
|
| 61 |
+
+ is_nasb_context = True
|
| 62 |
+
|
| 63 |
+
- is_present_tense = word.startswith('ي') or word.startswith('ت') or word.startswith('ن') or word.startswith('أ')
|
| 64 |
+
+ is_present_tense = word.startswith('ي') or word.startswith('ت') or word.startswith('ن') or word.startswith('أ') or word.startswith('لي') or word.startswith('لت') or word.startswith('ولي') or word.startswith('فلي')
|
| 65 |
+
if (pos_tag == 'verb' or is_present_tense) and (is_nasb_context or is_jazm_context):
|
| 66 |
+
if is_jazm_context:
|
| 67 |
+
word = self._apply_jazm_to_verb(word, token_info)
|
| 68 |
+
@@ -223,9 +204,14 @@ class ArabicGrammarGuard:
|
| 69 |
+
# When a feminine noun is followed by a masculine adjective, add ة
|
| 70 |
+
# e.g. السيارة جميل → السيارة جميلة
|
| 71 |
+
words = text.split()
|
| 72 |
+
+ KNOWN_MASC_TA_MARBUTA = {'خليفة', 'أسامة', 'حمزة', 'طلحة', 'معاوية', 'عبيدة', 'قضاة', 'دعاة', 'رماة', 'حماة'}
|
| 73 |
+
for i in range(len(words) - 1):
|
| 74 |
+
noun = words[i]
|
| 75 |
+
adj = words[i + 1]
|
| 76 |
+
+ # Strip prefixes like 'ال' to check root
|
| 77 |
+
+ clean_noun = noun[2:] if noun.startswith('ال') else noun
|
| 78 |
+
+ if clean_noun in KNOWN_MASC_TA_MARBUTA:
|
| 79 |
+
+ continue
|
| 80 |
+
is_fem_noun = (noun in KNOWN_FEMININE_NOUNS or
|
| 81 |
+
(noun.endswith('ة') and len(noun) >= 3) or
|
| 82 |
+
(noun.startswith('ال') and noun.endswith('ة')))
|
| 83 |
+
@@ -250,6 +236,9 @@ class ArabicGrammarGuard:
|
| 84 |
+
'النيران', 'نيران', 'الألوان', 'ألوان', 'البلدان', 'بلدان',
|
| 85 |
+
'الأوطان', 'أوطان', 'الأبدان', 'أبدان', 'الأركان', 'أركان',
|
| 86 |
+
'الفرسان', 'فرسان', 'الغزلان', 'غزلان', 'القضبان', 'قضبان',
|
| 87 |
+
+ 'فرعون', 'قانون', 'القانون', 'كانون', 'قارون', 'طاعون',
|
| 88 |
+
+ 'عربون', 'هارون', 'زيدون', 'معجون', 'مجنون', 'زيتون', 'صابون',
|
| 89 |
+
+ 'الفرعون', 'قوانين'
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
def fix_prepositions_advanced(self, text):
|
| 93 |
+
@@ -261,31 +250,36 @@ class ArabicGrammarGuard:
|
| 94 |
+
stem = m.group(2)
|
| 95 |
+
suffix = m.group(3)
|
| 96 |
+
full_word = stem + suffix
|
| 97 |
+
- # Skip words in blocklist (root nouns, not duals)
|
| 98 |
+
- if full_word in self._PREP_BLOCKLIST:
|
| 99 |
+
- return m.group(0) # return unchanged
|
| 100 |
+
- # Skip ال-prefixed words ending in ان — almost always root nouns
|
| 101 |
+
- if stem.startswith('ال') and suffix == 'ان':
|
| 102 |
+
- return m.group(0) # return unchanged
|
| 103 |
+
- return f'{prep} {stem}ين'
|
| 104 |
+
-
|
| 105 |
+
- text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن|حتى))\s+([أ-ي]{4,})(ون|ان)\b', _prep_replace, text)
|
| 106 |
+
+
|
| 107 |
+
+ # Use camel-tools disambiguation to determine if it's really a dual/plural
|
| 108 |
+
+ tokens = simple_word_tokenize(full_word)
|
| 109 |
+
+ disambig_tokens = self.mle.disambiguate(tokens)
|
| 110 |
+
+ if disambig_tokens and disambig_tokens[0].analyses:
|
| 111 |
+
+ num = disambig_tokens[0].analyses[0].analysis.get('num', 's')
|
| 112 |
+
+ # Only apply ين suffix if the word is actually Dual or Plural
|
| 113 |
+
+ if num in ['d', 'p']:
|
| 114 |
+
+ return f'{prep} {stem}ين'
|
| 115 |
+
+ return m.group(0)
|
| 116 |
+
+
|
| 117 |
+
+ text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن|حتى))\s+([أ-ي]{3,})(ون|ان)\b', _prep_replace, text)
|
| 118 |
+
|
| 119 |
+
# (وبالمبرمجون) -> (وبالمبرمجين)
|
| 120 |
+
# FIX-33b: Same blocklist protection as first regex
|
| 121 |
+
def _attached_prep_replace(m):
|
| 122 |
+
- prefix = m.group(1) # وب، ب، فب، ول، ل، etc.
|
| 123 |
+
+ prefix = m.group(1)
|
| 124 |
+
stem = m.group(2)
|
| 125 |
+
suffix = m.group(3)
|
| 126 |
+
- full_word = 'ال' + stem + suffix # reconstruct with ال for blocklist check
|
| 127 |
+
- if full_word in self._PREP_BLOCKLIST:
|
| 128 |
+
- return m.group(0)
|
| 129 |
+
- # Words ending in ان with 4+ char stems are almost always root nouns
|
| 130 |
+
- if suffix == 'ان':
|
| 131 |
+
- return m.group(0)
|
| 132 |
+
- return f'{prefix}ال{stem}ين'
|
| 133 |
+
+ full_word = 'ال' + stem + suffix
|
| 134 |
+
+
|
| 135 |
+
+ tokens = simple_word_tokenize(full_word)
|
| 136 |
+
+ disambig_tokens = self.mle.disambiguate(tokens)
|
| 137 |
+
+ if disambig_tokens and disambig_tokens[0].analyses:
|
| 138 |
+
+ num = disambig_tokens[0].analyses[0].analysis.get('num', 's')
|
| 139 |
+
+ if num in ['d', 'p']:
|
| 140 |
+
+ return f'{prefix}ال{stem}ين'
|
| 141 |
+
+ return m.group(0)
|
| 142 |
+
|
| 143 |
+
- text = re.sub(r'\b([وف]?[بلكف])ال([أ-ي]{4,})(ون|ان)\b', _attached_prep_replace, text)
|
| 144 |
+
+ text = re.sub(r'\b([وف]?[بلكف])ال([أ-ي]{3,})(ون|ان)\b', _attached_prep_replace, text)
|
| 145 |
+
|
| 146 |
+
# (ولمهندسون) -> (ولمهندسين)
|
| 147 |
+
# FIX-33b: Same protection — reconstruct full word for blocklist
|
| 148 |
+
@@ -321,8 +315,8 @@ class ArabicGrammarGuard:
|
| 149 |
+
if word == 'ذو': return 'ذا'
|
| 150 |
+
if word == 'فو': return 'فا'
|
| 151 |
+
if word == 'حمو': return 'حما'
|
| 152 |
+
- if word.endswith('ون'): return word[:-2] + 'ين'
|
| 153 |
+
- if word.endswith('ان'): return word[:-2] + 'ين'
|
| 154 |
+
+ if word.endswith('ون') and num == 'p' and word not in ('قانون', 'فرعون', 'كانون', 'معجون', 'طاعون', 'مجنون'): return word[:-2] + 'ين'
|
| 155 |
+
+ if word.endswith('ان') and num == 'd' and word not in ('امتحان', 'إنسان', 'ميدان', 'سلطان', 'شيطان'): return word[:-2] + 'ين'
|
| 156 |
+
elif target_case == 'n': # Marfoo'
|
| 157 |
+
if word in ('أبا', 'أبي'): return 'أبو'
|
| 158 |
+
if word in ('أخا', 'أخي'): return 'أخو'
|
| 159 |
+
@@ -481,30 +475,18 @@ class ArabicGrammarGuard:
|
| 160 |
+
# FIX-08: Expanded feminine plurals
|
| 161 |
+
'المهندسات', 'مهندسات', 'الطبيبات', 'طبيبات',
|
| 162 |
+
'اللاعبات', 'لاعبات', 'الممثلات', 'ممثلات',
|
| 163 |
+
- 'الشركات', 'شركات', 'الجامعات', 'جامعات',
|
| 164 |
+
- 'المدارس', 'مدارس', 'المستشفيات', 'مستشفيات',
|
| 165 |
+
- 'الحكومات', 'حكومات', 'المنظمات', 'منظمات',
|
| 166 |
+
- 'الطائرات', 'طائرات', 'السيارات', 'سيارات',
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
- if noun_word in KNOWN_PLURALS_MASC:
|
| 170 |
+
+ if noun_num == 'd':
|
| 171 |
+
+ pass # Dual noun, skip plural logic to prevent corruption
|
| 172 |
+
+ elif noun_word in KNOWN_PLURALS_MASC:
|
| 173 |
+
is_plural_masc = True
|
| 174 |
+
elif noun_word in KNOWN_PLURALS_FEM:
|
| 175 |
+
is_plural_fem = True
|
| 176 |
+
- elif noun_word.endswith('ون') or noun_word.endswith('ين'):
|
| 177 |
+
- # Sound masculine plural — but only if 4+ chars (avoid short words)
|
| 178 |
+
- if len(noun_word) >= 5:
|
| 179 |
+
- is_plural_masc = True
|
| 180 |
+
- elif noun_word.endswith('ات') and len(noun_word) >= 5:
|
| 181 |
+
- is_plural_fem = True
|
| 182 |
+
- # FIX-08: Broken plural heuristic — common patterns
|
| 183 |
+
- elif noun_num == 'p':
|
| 184 |
+
- # Trust POS tagger when it says plural AND word is long enough
|
| 185 |
+
- if len(noun_word) >= 4:
|
| 186 |
+
- if noun_gen == 'f':
|
| 187 |
+
- is_plural_fem = True
|
| 188 |
+
- else:
|
| 189 |
+
- is_plural_masc = True
|
| 190 |
+
+ elif (noun_word.endswith('ون') or (noun_word.endswith('ين') and noun_num == 'p')) and len(noun_word) >= 5:
|
| 191 |
+
+ # Bug 1.14: Check noun_num == 'p' to prevent duals from triggering plural logic
|
| 192 |
+
+ is_plural_masc = True
|
| 193 |
+
+ # Bug 1.2: Disabled POS tagger heuristic for broken plurals and generic 'ات' ending because it forces 'ن' on non-human plurals
|
| 194 |
+
|
| 195 |
+
is_singular_fem = False
|
| 196 |
+
if not is_plural_masc and not is_plural_fem:
|
| 197 |
+
@@ -574,7 +556,8 @@ class ArabicGrammarGuard:
|
| 198 |
+
return word
|
| 199 |
+
|
| 200 |
+
# إن وأخواتها
|
| 201 |
+
- text = re.sub(r'\b(إن|أن|كأن|لكن|لعل|ليت|ان|كان)\s+(أبوك|ابوك|أخوك|اخوك|ذو|فوك)\b',
|
| 202 |
+
+ # Bug 1.13: removed كان from this regex because it caused كان أخوك -> كان أخاك
|
| 203 |
+
+ text = re.sub(r'\b(إن|أن|كأن|لكن|لعل|ليت|ان)\s+(أبوك|ابوك|أخوك|اخوك|ذو|فوك)\b',
|
| 204 |
+
lambda m: f"{m.group(1)} {_add_hamza(m.group(2)).replace('و', 'ا')}", text)
|
| 205 |
+
|
| 206 |
+
# الأفعال المتعدية (Object position)
|
| 207 |
+
@@ -596,19 +579,19 @@ class ArabicGrammarGuard:
|
| 208 |
+
|
| 209 |
+
# FIX-PC010: Add targeted safe regex for Nasb/Jazm particles + verb
|
| 210 |
+
# Only match clear present tense verbs starting with ي/ت/ن/أ and ending in ون
|
| 211 |
+
- text = re.sub(r'\b(أن|ان|لن|كي|حتى|لم|لما)\s+([يتا][\u0600-\u06FF]{2,})ون\b',
|
| 212 |
+
+ text = re.sub(r'\b(أن|ان|لن|كي|حتى|لم|لما)\s+([يتاأن][\u0600-\u06FF]{2,})ون\b',
|
| 213 |
+
r'\1 \2وا', text)
|
| 214 |
+
|
| 215 |
+
return text
|
| 216 |
+
|
| 217 |
+
def fix_conditional_sentences(self, text):
|
| 218 |
+
- conditional_particles = {'إن', 'ان', 'من', 'ما', 'متى', 'متي', 'مهما', 'أينما', 'حيثما', 'أيان', 'ايان', 'كيفما', 'أنى', 'اني'}
|
| 219 |
+
+ conditional_particles = {'إن', 'ان', 'متى', 'متي', 'مهما', 'أينما', 'حيثما', 'أيان', 'ايان', 'كيفما', 'أنى', 'اني'}
|
| 220 |
+
tokens = simple_word_tokenize(text)
|
| 221 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 222 |
+
corrected_tokens = list(tokens)
|
| 223 |
+
|
| 224 |
+
# Lookahead for 2nd person context
|
| 225 |
+
- has_2nd_person_context = any(t.endswith('كم') or t.endswith('كمو') or t.startswith('ت') for t in tokens)
|
| 226 |
+
+ has_2nd_person_context = False
|
| 227 |
+
|
| 228 |
+
in_cond = False
|
| 229 |
+
verbs_jazmed = 0
|
| 230 |
+
@@ -634,10 +617,6 @@ class ArabicGrammarGuard:
|
| 231 |
+
# Apply jazm using the comprehensive camel_tools helper
|
| 232 |
+
word = self._apply_jazm_to_verb(word, token_info)
|
| 233 |
+
|
| 234 |
+
- # Fix pronoun mismatch if 2nd person context exists
|
| 235 |
+
- if has_2nd_person_context and word.startswith('ي') and (word.endswith('وا') or word.endswith('ا') or word.endswith('ي')):
|
| 236 |
+
- word = 'ت' + word[1:]
|
| 237 |
+
-
|
| 238 |
+
corrected_tokens[i] = word
|
| 239 |
+
# Increment jazmed verbs counter (handles both فعل الشرط and جواب الشرط)
|
| 240 |
+
verbs_jazmed += 1
|
| 241 |
+
@@ -714,8 +693,8 @@ class ArabicGrammarGuard:
|
| 242 |
+
else:
|
| 243 |
+
corrected_tokens[i+1] = base_adj + ('ان' if is_nom else 'ين')
|
| 244 |
+
|
| 245 |
+
- # Plural Human Adjective Agreement
|
| 246 |
+
- elif w1_num == 'p' and w1_pos in ['noun', 'unknown']:
|
| 247 |
+
+ # Plural Human Adjective Agreement (Sound Plurals Only)
|
| 248 |
+
+ elif w1_num == 'p' and w1_pos in ['noun', 'unknown'] and (w1.endswith('ون') or w1.endswith('ين') or w1.endswith('ات')):
|
| 249 |
+
base_adj = None
|
| 250 |
+
for suffix in ['ان', 'ين', 'تان', 'تين', 'ة', 'ون', 'ات', 'ين', '']:
|
| 251 |
+
stem = w2[:-len(suffix)] if suffix else w2
|
| 252 |
+
@@ -726,9 +705,11 @@ class ArabicGrammarGuard:
|
| 253 |
+
if base_adj:
|
| 254 |
+
if w1.endswith('ون') or w1.endswith('ين') or w1_gen == 'm':
|
| 255 |
+
is_nom = w1.endswith('ون')
|
| 256 |
+
- corrected_tokens[i+1] = base_adj + ('ون' if is_nom else 'ين')
|
| 257 |
+
+ if not w2.endswith('ان') and not w2.endswith('ين') and not w2.endswith('ات'):
|
| 258 |
+
+ corrected_tokens[i+1] = base_adj + ('ون' if is_nom else 'ين')
|
| 259 |
+
elif w1.endswith('ات') or w1_gen == 'f':
|
| 260 |
+
- corrected_tokens[i+1] = base_adj + 'ات'
|
| 261 |
+
+ if not w2.endswith('ان') and not w2.endswith('ين') and not w2.endswith('ات'):
|
| 262 |
+
+ corrected_tokens[i+1] = base_adj + 'ات'
|
| 263 |
+
|
| 264 |
+
return " ".join(corrected_tokens)
|
| 265 |
+
|
| 266 |
+
@@ -866,8 +847,9 @@ class ArabicGrammarGuard:
|
| 267 |
+
}
|
| 268 |
+
words = text.split()
|
| 269 |
+
for i, w in enumerate(words):
|
| 270 |
+
- if w in _ALWAYS_TANWEEN:
|
| 271 |
+
- words[i] = _ALWAYS_TANWEEN[w]
|
| 272 |
+
+ clean_w = w.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 273 |
+
+ if clean_w in _ALWAYS_TANWEEN:
|
| 274 |
+
+ words[i] = _ALWAYS_TANWEEN[clean_w] + w[len(clean_w):]
|
| 275 |
+
return ' '.join(words)
|
| 276 |
+
|
| 277 |
+
def fix_initial_hamza(self, text):
|
| 278 |
+
@@ -920,16 +902,27 @@ class ArabicGrammarGuard:
|
| 279 |
+
_PRONOUN_SUFFIXES = {'ه', 'ها', 'ك', 'كم', 'كن', 'هم', 'هن', 'ني', 'نا'}
|
| 280 |
+
words = text.split()
|
| 281 |
+
for i, w in enumerate(words):
|
| 282 |
+
- if w in _HAMZA_FIXES:
|
| 283 |
+
- words[i] = _HAMZA_FIXES[w]
|
| 284 |
+
+ clean_w = w.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 285 |
+
+ if clean_w in _HAMZA_FIXES:
|
| 286 |
+
+ words[i] = _HAMZA_FIXES[clean_w] + w[len(clean_w):]
|
| 287 |
+
continue
|
| 288 |
+
# إنّ/أنّ: kasra at sentence start, fathah mid-sentence
|
| 289 |
+
_is_sent_start = (i == 0) or (words[i-1][-1] in '.؟!؛' if words[i-1] else False)
|
| 290 |
+
- if _is_sent_start and w in _INNA_SENTENCE_INITIAL:
|
| 291 |
+
- words[i] = _INNA_SENTENCE_INITIAL[w]
|
| 292 |
+
+ if _is_sent_start and clean_w in _INNA_SENTENCE_INITIAL:
|
| 293 |
+
+ words[i] = _INNA_SENTENCE_INITIAL[clean_w] + w[len(clean_w):]
|
| 294 |
+
continue
|
| 295 |
+
- if not _is_sent_start and w in _ANNA_MID_SENTENCE:
|
| 296 |
+
- words[i] = _ANNA_MID_SENTENCE[w]
|
| 297 |
+
+ if not _is_sent_start and clean_w in _ANNA_MID_SENTENCE:
|
| 298 |
+
+ if clean_w == 'ان':
|
| 299 |
+
+ # Bug 1.5: Added تقول and all variants to Qawl check
|
| 300 |
+
+ if i > 0 and words[i-1].rstrip('.,،؛;:!؟?()[]{}«»"\'…') in ('قال', 'قالت', 'يقول', 'يقولون', 'قلت', 'قلنا', 'تقول', 'يقولوا'):
|
| 301 |
+
+ words[i] = 'إن' + w[len(clean_w):]
|
| 302 |
+
+ else:
|
| 303 |
+
+ pass # leave it alone to avoid breaking conditional
|
| 304 |
+
+ else:
|
| 305 |
+
+ if i > 0 and words[i-1].rstrip('.,،؛;:!؟?()[]{}«»"\'…') in ('قال', 'قالت', 'يقول', 'يقولون', 'قلت', 'قلنا', 'تقول', 'يقولوا'):
|
| 306 |
+
+ words[i] = _INNA_SENTENCE_INITIAL[clean_w] + w[len(clean_w):]
|
| 307 |
+
+ else:
|
| 308 |
+
+ words[i] = _ANNA_MID_SENTENCE[clean_w] + w[len(clean_w):]
|
| 309 |
+
continue
|
| 310 |
+
for stem, fixed in _HAMZA_STEMS.items():
|
| 311 |
+
if w.startswith(stem) and len(w) > len(stem):
|
diff_punc.txt
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/src/nlp/punctuation/punctuation_rules.py b/src/nlp/punctuation/punctuation_rules.py
|
| 2 |
+
index 0d44de2..43b59d8 100644
|
| 3 |
+
--- a/src/nlp/punctuation/punctuation_rules.py
|
| 4 |
+
+++ b/src/nlp/punctuation/punctuation_rules.py
|
| 5 |
+
@@ -60,7 +60,9 @@ def arabic_postprocessing(text: str) -> str:
|
| 6 |
+
# Only applies if a colon is actually present on the verb or the name
|
| 7 |
+
def _fix_misplaced(m):
|
| 8 |
+
verb, col1, name, col2 = m.groups()
|
| 9 |
+
- if col1 == ':' or col2 == ':':
|
| 10 |
+
+ if col1 == ':':
|
| 11 |
+
+ return f"{verb}: {name}"
|
| 12 |
+
+ if col2 == ':':
|
| 13 |
+
return f"{verb} {name}:"
|
| 14 |
+
return m.group(0)
|
| 15 |
+
|
| 16 |
+
@@ -87,9 +89,9 @@ def arabic_postprocessing(text: str) -> str:
|
| 17 |
+
return match.group(0)
|
| 18 |
+
|
| 19 |
+
if prev_word.startswith(('ال', 'لل', 'بال', 'فال', 'وال', 'كال')):
|
| 20 |
+
- return context + " "
|
| 21 |
+
+ return match.group(0) # Preserve the colon! Do not delete it.
|
| 22 |
+
|
| 23 |
+
- return match.group(0)
|
| 24 |
+
+ return context + ' '
|
| 25 |
+
|
| 26 |
+
text = re.sub(r'([^:]+)(:)', _colon_guard, text)
|
| 27 |
+
|
| 28 |
+
@@ -132,16 +134,11 @@ _EXCL_CUES = {'يا', 'ما', 'كم', 'لا', 'هل', 'أين', 'متى',
|
| 29 |
+
def _normalize_for_comparison(text: str) -> str:
|
| 30 |
+
"""
|
| 31 |
+
Normalize Arabic for safe comparison.
|
| 32 |
+
- Prevents false rejection from hamza/alef/ya variants.
|
| 33 |
+
+ Only removes diacritics to prevent punctuation model from stripping harakat.
|
| 34 |
+
+ Does NOT fold hamza/ya/ta-marbuta to ensure we catch spelling regressions!
|
| 35 |
+
"""
|
| 36 |
+
# Remove diacritics
|
| 37 |
+
text = re.sub(r'[\u064B-\u0652]', '', text)
|
| 38 |
+
- # Fold hamza/alef variants: أ إ آ → ا
|
| 39 |
+
- text = re.sub(r'[أإآ]', 'ا', text)
|
| 40 |
+
- # Fold ya: ى → ي
|
| 41 |
+
- text = text.replace('ى', 'ي')
|
| 42 |
+
- # Fold ta marbuta: ة → ه (comparison only)
|
| 43 |
+
- text = text.replace('ة', 'ه')
|
| 44 |
+
return text
|
| 45 |
+
|
| 46 |
+
|
diff_spell.txt
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/src/nlp/spelling/araspell_rules.py b/src/nlp/spelling/araspell_rules.py
|
| 2 |
+
index 39d02b7..0102cae 100644
|
| 3 |
+
--- a/src/nlp/spelling/araspell_rules.py
|
| 4 |
+
+++ b/src/nlp/spelling/araspell_rules.py
|
| 5 |
+
@@ -129,14 +129,9 @@ class AraSpellPostProcessor:
|
| 6 |
+
@staticmethod
|
| 7 |
+
def remove_duplicate_words(text: str) -> str:
|
| 8 |
+
"""Remove consecutive duplicate words. e.g. كتاب كتاب → كتاب"""
|
| 9 |
+
- words = text.split()
|
| 10 |
+
- if len(words) < 2:
|
| 11 |
+
- return text
|
| 12 |
+
- result = [words[0]]
|
| 13 |
+
- for i in range(1, len(words)):
|
| 14 |
+
- if words[i] != words[i-1]:
|
| 15 |
+
- result.append(words[i])
|
| 16 |
+
- return ' '.join(result)
|
| 17 |
+
+ # Bug 2.11: Destroys rhetorical repetition (التوكيد اللفظي) like "صفا صفا".
|
| 18 |
+
+ # Disabled as it destroys valid Arabic phrases.
|
| 19 |
+
+ return text
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def normalize_spaces(text: str) -> str:
|
| 23 |
+
@@ -154,17 +149,9 @@ class AraSpellPostProcessor:
|
| 24 |
+
@staticmethod
|
| 25 |
+
def remove_word_repetition_with_wa(text: str) -> str:
|
| 26 |
+
"""Remove word و word → word"""
|
| 27 |
+
- words = text.split()
|
| 28 |
+
- result = []
|
| 29 |
+
- i = 0
|
| 30 |
+
- while i < len(words):
|
| 31 |
+
- if i + 2 < len(words) and words[i] == words[i+2] and words[i+1] == 'و':
|
| 32 |
+
- result.append(words[i])
|
| 33 |
+
- i += 3
|
| 34 |
+
- else:
|
| 35 |
+
- result.append(words[i])
|
| 36 |
+
- i += 1
|
| 37 |
+
- return ' '.join(result)
|
| 38 |
+
+ # Bug 2.9: This deletes valid rhetorical repetition (التوكيد اللفظي) like "صنفا وصنفا"
|
| 39 |
+
+ # Disabled as it is highly destructive to valid Arabic.
|
| 40 |
+
+ return text
|
| 41 |
+
|
| 42 |
+
# --- Hamza & Ta Marbuta Handling ---
|
| 43 |
+
|
| 44 |
+
@@ -181,7 +168,7 @@ class AraSpellPostProcessor:
|
| 45 |
+
'اذا': 'إذا', 'اذ': 'إذ',
|
| 46 |
+
'اي': 'أي', 'اين': 'أين',
|
| 47 |
+
'او': 'أو',
|
| 48 |
+
- 'اما': 'أما',
|
| 49 |
+
+
|
| 50 |
+
'ان': 'أن', 'انه': 'أنه', 'انها': 'أنها', 'انهم': 'أنهم',
|
| 51 |
+
'اخر': 'آخر', 'اخرى': 'أخرى',
|
| 52 |
+
'الان': 'الآن',
|
| 53 |
+
@@ -201,9 +188,9 @@ class AraSpellPostProcessor:
|
| 54 |
+
'اهل': 'أهل',
|
| 55 |
+
'اطفال': 'أطفال',
|
| 56 |
+
'اصدقاء': 'أصدقاء', 'اصدقائي': 'أصدقائي',
|
| 57 |
+
- 'اعتقد': 'أعتقد', 'اريد': 'أريد', 'احب': 'أحب',
|
| 58 |
+
- 'اعرف': 'أعرف', 'اعلم': 'أعلم',
|
| 59 |
+
- 'اخذ': 'أخذ', 'اكل': 'أكل',
|
| 60 |
+
+ 'اريد': 'أريد', 'احب': 'أحب',
|
| 61 |
+
+ 'اعلم': 'أعلم',
|
| 62 |
+
+ 'اكل': 'أكل',
|
| 63 |
+
'الايام': 'الأيام',
|
| 64 |
+
'الاطفال': 'الأطفال',
|
| 65 |
+
'الاسعار': 'الأسعار',
|
| 66 |
+
@@ -243,10 +230,8 @@ class AraSpellPostProcessor:
|
| 67 |
+
'ادارة': 'إدارة', 'ادارية': 'إدارية',
|
| 68 |
+
'اعلام': 'إعلام', 'اعلامي': 'إعلامي',
|
| 69 |
+
'احتمال': 'احتمال', 'احتفال': 'احتفال',
|
| 70 |
+
- 'ازور': 'أزور', 'اذهب': 'أذهب', 'اكتب': 'أكتب',
|
| 71 |
+
'اقرا': 'أقرأ', 'اقرأ': 'أقرأ',
|
| 72 |
+
- 'اعمل': 'أعمل', 'ادرس': 'أدرس',
|
| 73 |
+
- 'اشتري': 'أشتري', 'اسافر': 'أسافر',
|
| 74 |
+
+ 'اسافر': 'أسافر',
|
| 75 |
+
'احبه': 'أحبه',
|
| 76 |
+
'مسؤول': 'مسؤول', 'مسؤولية': 'مسؤولية',
|
| 77 |
+
'رؤية': 'رؤية', 'رؤيا': 'رؤيا',
|
| 78 |
+
@@ -259,7 +244,7 @@ class AraSpellPostProcessor:
|
| 79 |
+
'مصطفي': 'مصطفى', 'موسي': 'موسى', 'عيسي': 'عيسى',
|
| 80 |
+
'هدي': 'هدى', 'بني': 'بنى',
|
| 81 |
+
'معني': 'معنى', 'مبني': 'مبنى',
|
| 82 |
+
- 'علي': 'على', # Common alif maqsura confusion
|
| 83 |
+
+
|
| 84 |
+
'الي': 'إلى',
|
| 85 |
+
# FIX-47: Verb+pronoun hamza entries (احبه→أحبه)
|
| 86 |
+
'احبه': 'أحبه', 'احبها': 'أحبها', 'احبك': 'أحبك',
|
| 87 |
+
@@ -280,16 +265,9 @@ class AraSpellPostProcessor:
|
| 88 |
+
@staticmethod
|
| 89 |
+
def fix_hamza_conservative(text: str) -> str:
|
| 90 |
+
"""Conservative Hamza normalization — only at word END, not middle."""
|
| 91 |
+
- words = text.split()
|
| 92 |
+
- result = []
|
| 93 |
+
- for word in words:
|
| 94 |
+
- if len(word) >= 3:
|
| 95 |
+
- if word.endswith('أ'):
|
| 96 |
+
- word = word[:-1] + 'ا'
|
| 97 |
+
- if word.endswith('إ'):
|
| 98 |
+
- word = word[:-1] + 'ا'
|
| 99 |
+
- result.append(word)
|
| 100 |
+
- return ' '.join(result)
|
| 101 |
+
+ # Bug 2.5: Blindly changing أ at the end of word to ا corrupts valid orthography (قرأ -> قرا)
|
| 102 |
+
+ # Disabled as it is highly destructive.
|
| 103 |
+
+ return text
|
| 104 |
+
|
| 105 |
+
# Attached prefixes that can precede hamza-whitelist words
|
| 106 |
+
# Ordered longest-first so وال is tried before و
|
| 107 |
+
@@ -354,18 +332,22 @@ class AraSpellPostProcessor:
|
| 108 |
+
if any(word.endswith(e) for e in PROTECTED_ENDINGS):
|
| 109 |
+
result.append(word)
|
| 110 |
+
continue
|
| 111 |
+
- if word in PROTECTED_HA_WORDS:
|
| 112 |
+
+ if word in PROTECTED_HA_WORDS or word in ['هذه', 'هاته']:
|
| 113 |
+
result.append(word)
|
| 114 |
+
continue
|
| 115 |
+
if len(word) >= 3 and word.endswith('ه'):
|
| 116 |
+
- if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS:
|
| 117 |
+
+ if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS or word[-2] in 'اويءؤئ':
|
| 118 |
+
candidate_with_ta = word[:-1] + 'ة'
|
| 119 |
+
# Default: prefer ة (correct Arabic orthography for feminine nouns)
|
| 120 |
+
if vocab_manager:
|
| 121 |
+
ta_iv = vocab_manager.is_iv(candidate_with_ta)
|
| 122 |
+
ha_iv = vocab_manager.is_iv(word)
|
| 123 |
+
- if ta_iv:
|
| 124 |
+
- # Always prefer ة when it's a valid word
|
| 125 |
+
+ if ha_iv and ta_iv:
|
| 126 |
+
+ # Bug 2.2: Do not prefer ة if ه is also valid (possessive pronoun)
|
| 127 |
+
+ result.append(word)
|
| 128 |
+
+ continue
|
| 129 |
+
+ elif ta_iv:
|
| 130 |
+
+ # Prefer ة when ONLY the ة form is valid
|
| 131 |
+
result.append(candidate_with_ta)
|
| 132 |
+
continue
|
| 133 |
+
elif ha_iv:
|
| 134 |
+
@@ -401,11 +383,9 @@ class AraSpellPostProcessor:
|
| 135 |
+
word = word[:-1]
|
| 136 |
+
if i + 1 < len(words):
|
| 137 |
+
next_word = words[i + 1]
|
| 138 |
+
- if normalize_word(word) == normalize_word(next_word):
|
| 139 |
+
- keep = next_word if next_word.startswith('ال') and not word.startswith('ال') else word
|
| 140 |
+
- result.append(keep)
|
| 141 |
+
- i += 2
|
| 142 |
+
- continue
|
| 143 |
+
+ # Bug 2.11: Destroys Badal structures (الأستاذ أستاذ -> الأستاذ)
|
| 144 |
+
+ # and Rhetorical Repetition (التوكيد اللفظي)
|
| 145 |
+
+ # Removed the aggressive duplicate word deletion.
|
| 146 |
+
result.append(word)
|
| 147 |
+
i += 1
|
| 148 |
+
return ' '.join(result)
|
| 149 |
+
@@ -454,18 +434,8 @@ class AraSpellPostProcessor:
|
| 150 |
+
result.append(word + next_word)
|
| 151 |
+
i += 2
|
| 152 |
+
continue
|
| 153 |
+
- if len(word) >= 2 and len(next_word) >= 2 and word[-1] == next_word[0]:
|
| 154 |
+
- if not (word in STANDALONE_WORDS and next_word in STANDALONE_WORDS):
|
| 155 |
+
- result.append(word[:-1] + next_word)
|
| 156 |
+
- i += 2
|
| 157 |
+
- continue
|
| 158 |
+
- if (2 <= len(word) <= 4 and
|
| 159 |
+
- 1 <= len(next_word) <= 2 and
|
| 160 |
+
- 3 <= len(word) + len(next_word) <= 7):
|
| 161 |
+
- if not (word in STANDALONE_WORDS and next_word in STANDALONE_WORDS):
|
| 162 |
+
- result.append(word + next_word)
|
| 163 |
+
- i += 2
|
| 164 |
+
- continue
|
| 165 |
+
+ # Bug 2.3: Destructive word merging (يوم مشمس -> يومشمس)
|
| 166 |
+
+ # Removed generic boundary letter merging.
|
| 167 |
+
result.append(word)
|
| 168 |
+
i += 1
|
| 169 |
+
return ' '.join(result)
|
| 170 |
+
@@ -779,15 +749,7 @@ class WordAligner:
|
| 171 |
+
if in_iv and not out_iv:
|
| 172 |
+
return input_word
|
| 173 |
+
if in_iv and out_iv:
|
| 174 |
+
- # Fix S1: When only difference is ه→ة at word end, prefer ة
|
| 175 |
+
- # (correct Arabic orthography — ة is the standard feminine ending)
|
| 176 |
+
- if (input_word.endswith('ه') and output_word.endswith('ة')
|
| 177 |
+
- and input_word[:-1] == output_word[:-1]):
|
| 178 |
+
- return output_word
|
| 179 |
+
- # Fix S1: Also handle ة→ه (don't regress a correct ة to ه)
|
| 180 |
+
- if (input_word.endswith('ة') and output_word.endswith('ه')
|
| 181 |
+
- and input_word[:-1] == output_word[:-1]):
|
| 182 |
+
- return input_word
|
| 183 |
+
+ # Bug 2.2: Do not prefer ة over ه if both are IV, because ه is often a valid possessive pronoun.
|
| 184 |
+
return input_word
|
| 185 |
+
if len(input_word) == len(output_word) and len(input_word) >= 3:
|
| 186 |
+
for i in range(len(input_word)):
|
| 187 |
+
@@ -1207,55 +1169,35 @@ class ArabicSpellChecker:
|
| 188 |
+
logger.info("[MLM/CONTEXTUAL] Disabled by configuration (use_contextual=False)")
|
| 189 |
+
|
| 190 |
+
def _fix_repeated_end_chars(self, text: str) -> str:
|
| 191 |
+
- text = re.sub(r'([ا-ي])\1+\b', r'\1', text)
|
| 192 |
+
+ # Exclude 'ي' if it is preceded by a Kasra or another Yaa (e.g., يحيي)
|
| 193 |
+
+ def _replace_repeated(m):
|
| 194 |
+
+ w = m.group(0)
|
| 195 |
+
+ char = m.group(2)
|
| 196 |
+
+ if w.endswith('يي'):
|
| 197 |
+
+ if self.vocab_manager and self.vocab_manager.is_iv(w):
|
| 198 |
+
+ return w
|
| 199 |
+
+ return m.group(1) + char
|
| 200 |
+
+ text = re.sub(r'\b([^\s]+?)([\u0621-\u064A])\2+\b', _replace_repeated, text)
|
| 201 |
+
return text
|
| 202 |
+
|
| 203 |
+
def _fix_merged_with_errors(self, text: str) -> str:
|
| 204 |
+
- text = re.sub(r'ال([ا-ي])\1+([ا-ي]{2,})', r'ال\2', text)
|
| 205 |
+
+ # Bug 2.10: This regex was r'ال\2', deleting all instances of the character
|
| 206 |
+
+ text = re.sub(r'ال([ا-ي])\1+([ا-ي]{2,})', r'ال\1\2', text)
|
| 207 |
+
text = re.sub(r'\b([ا-ي]{3,})([ا-ي])\2+\b', r'\1\2', text)
|
| 208 |
+
return text
|
| 209 |
+
|
| 210 |
+
def _split_merged_words_linguistic(self, text: str) -> str:
|
| 211 |
+
- text = re.sub(
|
| 212 |
+
- r'\b(في|من|إلى|الى|حتى|منذ|خلال|بعد|قبل)(ال)?([ا-ي]{3,})',
|
| 213 |
+
- r'\1 \2\3', text
|
| 214 |
+
- )
|
| 215 |
+
- text = re.sub(r'\b(كل)([ا-ي]{3,})', r'\1 \2', text)
|
| 216 |
+
- text = re.sub(r'([ا-ي]{3,})(ال)([ا-ي]{3,})', r'\1 \2\3', text)
|
| 217 |
+
- text = re.sub(r'\b([بلك])(ال)?([ا-ي]{3,})', r'\1 \2\3', text)
|
| 218 |
+
- text = re.sub(r'([ا-ي]{4,})(عليكم|عليك|عليه|عليها)', r'\1 \2', text)
|
| 219 |
+
- text = re.sub(r'([ا-ي]{3,})(على|عن)([ا-ي]{3,})', r'\1 \2 \3', text)
|
| 220 |
+
+ # Bug 2.7: Catastrophic preposition splitting (e.g. منطق -> من طق)
|
| 221 |
+
+ # Disabled generic regex splitting as it is highly destructive to valid vocabulary.
|
| 222 |
+
return text
|
| 223 |
+
|
| 224 |
+
def _split_long_words_heuristic(self, text: str, max_length: int = 15) -> str:
|
| 225 |
+
- words = text.split()
|
| 226 |
+
- result = []
|
| 227 |
+
- for word in words:
|
| 228 |
+
- if len(word) <= max_length:
|
| 229 |
+
- result.append(word)
|
| 230 |
+
- continue
|
| 231 |
+
- if 'ال' in word[2:]:
|
| 232 |
+
- parts = word.split('ال', 1)
|
| 233 |
+
- if len(parts[0]) >= 2 and len(parts[1]) >= 3:
|
| 234 |
+
- result.extend([parts[0], 'ال' + parts[1]])
|
| 235 |
+
- continue
|
| 236 |
+
- if len(word) >= 8:
|
| 237 |
+
- split_found = False
|
| 238 |
+
- for split_pos in [2, 3]:
|
| 239 |
+
- prefix = word[:split_pos]
|
| 240 |
+
- suffix = word[split_pos:]
|
| 241 |
+
- if prefix in ['في', 'من', 'على', 'عن', 'مع', 'كل', 'ب', 'ل', 'ك']:
|
| 242 |
+
- result.extend([prefix, suffix])
|
| 243 |
+
- split_found = True
|
| 244 |
+
- break
|
| 245 |
+
- if not split_found:
|
| 246 |
+
- result.append(word)
|
| 247 |
+
- else:
|
| 248 |
+
- result.append(word)
|
| 249 |
+
- return ' '.join(result)
|
| 250 |
+
+ # Bug 2.8: Overzealous long word splitting (e.g. فيتامينات -> في تامينات)
|
| 251 |
+
+ # Disabled as it creates more errors than it fixes.
|
| 252 |
+
+ return text
|
| 253 |
+
|
| 254 |
+
def _normalize_tanween_patterns(self, text: str) -> str:
|
| 255 |
+
- text = re.sub(r'([ا-ي]{2,})أ\b', r'\1اً', text)
|
| 256 |
+
+ # Bug 2.6: Blind replacement of trailing أ with اً corrupts verbs and nominative cases (قرأ -> قراً)
|
| 257 |
+
text = re.sub(r'\s+أ\s+', ' ', text)
|
| 258 |
+
text = re.sub(r'\b([بلك])\s+([ا-ي])', r'\1\2', text)
|
| 259 |
+
return text
|
| 260 |
+
@@ -1661,3 +1603,4 @@ class ArabicSpellChecker:
|
| 261 |
+
result = ' '.join(res_words_list)
|
| 262 |
+
|
| 263 |
+
return result
|
| 264 |
+
+
|
extension/content-inline.css
CHANGED
|
@@ -395,18 +395,22 @@
|
|
| 395 |
font-weight: 700 !important;
|
| 396 |
background: linear-gradient(135deg, #8BB8E8, #6BA3E0) !important;
|
| 397 |
-webkit-background-clip: text !important;
|
|
|
|
| 398 |
-webkit-text-fill-color: transparent !important;
|
| 399 |
letter-spacing: -0.3px !important;
|
| 400 |
}
|
| 401 |
|
| 402 |
.bayan-il-modal-close {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
background: none !important;
|
| 404 |
border: none !important;
|
| 405 |
color: #8A939F !important;
|
| 406 |
cursor: pointer !important;
|
| 407 |
-
|
| 408 |
-
padding: 4px !important;
|
| 409 |
-
line-height: 1 !important;
|
| 410 |
border-radius: 6px !important;
|
| 411 |
transition: background 150ms ease, color 150ms ease !important;
|
| 412 |
}
|
|
@@ -553,9 +557,11 @@
|
|
| 553 |
/* ── Header Divider ── */
|
| 554 |
|
| 555 |
.bayan-il-header-divider {
|
| 556 |
-
width:
|
| 557 |
-
height:
|
| 558 |
background: #3a3a4d !important;
|
|
|
|
|
|
|
| 559 |
}
|
| 560 |
|
| 561 |
/* ── Suggestion Cards ── */
|
|
@@ -916,6 +922,15 @@
|
|
| 916 |
.bayan-il-popover-label {
|
| 917 |
font-weight: 700 !important;
|
| 918 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
.bayan-il-popover-alternatives {
|
| 920 |
display: flex !important;
|
| 921 |
flex-direction: column !important;
|
|
@@ -974,6 +989,58 @@
|
|
| 974 |
margin-bottom: 0 !important;
|
| 975 |
}
|
| 976 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 977 |
/* Modal Panel UI Sync */
|
| 978 |
.bayan-il-sidebar-panel {
|
| 979 |
display: flex !important;
|
|
@@ -1141,51 +1208,4 @@
|
|
| 1141 |
opacity: 0.9 !important;
|
| 1142 |
}
|
| 1143 |
|
| 1144 |
-
/* Modal header wrapper */
|
| 1145 |
-
.bayan-il-modal-top-bar {
|
| 1146 |
-
display: flex !important;
|
| 1147 |
-
align-items: center !important;
|
| 1148 |
-
justify-content: space-between !important;
|
| 1149 |
-
padding: 0 0.5rem !important;
|
| 1150 |
-
}
|
| 1151 |
-
.bayan-il-modal-brand {
|
| 1152 |
-
display: flex !important;
|
| 1153 |
-
align-items: center !important;
|
| 1154 |
-
gap: 0.5rem !important;
|
| 1155 |
-
}
|
| 1156 |
-
.bayan-il-modal-title {
|
| 1157 |
-
font-size: 1.25rem !important;
|
| 1158 |
-
font-weight: 700 !important;
|
| 1159 |
-
background: linear-gradient(135deg, #6BA3E0, #A594E8) !important;
|
| 1160 |
-
-webkit-background-clip: text !important;
|
| 1161 |
-
-webkit-text-fill-color: transparent !important;
|
| 1162 |
-
}
|
| 1163 |
-
.bayan-il-modal-close {
|
| 1164 |
-
background: none !important;
|
| 1165 |
-
border: none !important;
|
| 1166 |
-
color: #B4BBC6 !important;
|
| 1167 |
-
font-size: 1.25rem !important;
|
| 1168 |
-
cursor: pointer !important;
|
| 1169 |
-
}
|
| 1170 |
-
.bayan-il-modal-close:hover {
|
| 1171 |
-
color: #E88A8A !important;
|
| 1172 |
-
}
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
.bayan-il-header-divider {
|
| 1178 |
-
width: 1px !important;
|
| 1179 |
-
height: 18px !important;
|
| 1180 |
-
background: rgba(236, 238, 242, 0.2) !important;
|
| 1181 |
-
margin: 0 10px !important;
|
| 1182 |
-
}
|
| 1183 |
-
|
| 1184 |
-
.bayan-il-modal-top-bar {
|
| 1185 |
-
cursor: grab !important;
|
| 1186 |
-
}
|
| 1187 |
-
|
| 1188 |
-
.bayan-il-modal-top-bar:active {
|
| 1189 |
-
cursor: grabbing !important;
|
| 1190 |
-
}
|
| 1191 |
|
|
|
|
| 395 |
font-weight: 700 !important;
|
| 396 |
background: linear-gradient(135deg, #8BB8E8, #6BA3E0) !important;
|
| 397 |
-webkit-background-clip: text !important;
|
| 398 |
+
background-clip: text !important;
|
| 399 |
-webkit-text-fill-color: transparent !important;
|
| 400 |
letter-spacing: -0.3px !important;
|
| 401 |
}
|
| 402 |
|
| 403 |
.bayan-il-modal-close {
|
| 404 |
+
display: flex !important;
|
| 405 |
+
align-items: center !important;
|
| 406 |
+
justify-content: center !important;
|
| 407 |
+
width: 28px !important;
|
| 408 |
+
height: 28px !important;
|
| 409 |
background: none !important;
|
| 410 |
border: none !important;
|
| 411 |
color: #8A939F !important;
|
| 412 |
cursor: pointer !important;
|
| 413 |
+
padding: 0 !important;
|
|
|
|
|
|
|
| 414 |
border-radius: 6px !important;
|
| 415 |
transition: background 150ms ease, color 150ms ease !important;
|
| 416 |
}
|
|
|
|
| 557 |
/* ── Header Divider ── */
|
| 558 |
|
| 559 |
.bayan-il-header-divider {
|
| 560 |
+
width: 2px !important;
|
| 561 |
+
height: 24px !important;
|
| 562 |
background: #3a3a4d !important;
|
| 563 |
+
border-radius: 9999px !important;
|
| 564 |
+
flex-shrink: 0 !important;
|
| 565 |
}
|
| 566 |
|
| 567 |
/* ── Suggestion Cards ── */
|
|
|
|
| 922 |
.bayan-il-popover-label {
|
| 923 |
font-weight: 700 !important;
|
| 924 |
}
|
| 925 |
+
.bayan-il-popover-arrow {
|
| 926 |
+
color: #8A939F !important;
|
| 927 |
+
font-size: 12px !important;
|
| 928 |
+
margin: 0 4px !important;
|
| 929 |
+
}
|
| 930 |
+
.bayan-il-popover-correction {
|
| 931 |
+
color: #6BC98A !important;
|
| 932 |
+
font-weight: 600 !important;
|
| 933 |
+
}
|
| 934 |
.bayan-il-popover-alternatives {
|
| 935 |
display: flex !important;
|
| 936 |
flex-direction: column !important;
|
|
|
|
| 989 |
margin-bottom: 0 !important;
|
| 990 |
}
|
| 991 |
|
| 992 |
+
/* ── Tooltip Popover — Light Theme ── */
|
| 993 |
+
|
| 994 |
+
.bayan-il-suggestion-popover[data-bayan-theme="light"] {
|
| 995 |
+
background: #FAF9F6 !important;
|
| 996 |
+
border-color: rgba(26, 29, 33, 0.12) !important;
|
| 997 |
+
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.1) !important;
|
| 998 |
+
color: #1A1D21 !important;
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
.bayan-il-suggestion-popover[data-bayan-theme="light"] .bayan-il-popover-original-word {
|
| 1002 |
+
color: #3A424E !important;
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
.bayan-il-suggestion-popover[data-bayan-theme="light"] .bayan-il-tooltip-original {
|
| 1006 |
+
color: #C53030 !important;
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
.bayan-il-suggestion-popover[data-bayan-theme="light"] .bayan-il-popover-correction {
|
| 1010 |
+
color: #2F8554 !important;
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
.bayan-il-suggestion-popover[data-bayan-theme="light"] .bayan-il-popover-arrow {
|
| 1014 |
+
color: #5A6472 !important;
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
.bayan-il-suggestion-popover[data-bayan-theme="light"] .bayan-il-popover-alt-btn {
|
| 1018 |
+
background: #FAF9F6 !important;
|
| 1019 |
+
border-color: rgba(26, 29, 33, 0.12) !important;
|
| 1020 |
+
color: #1A1D21 !important;
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
.bayan-il-suggestion-popover[data-bayan-theme="light"] .bayan-il-popover-alt-main {
|
| 1024 |
+
background: linear-gradient(135deg, #2B6CB8, #6B57A8) !important;
|
| 1025 |
+
color: white !important;
|
| 1026 |
+
border-color: transparent !important;
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
.bayan-il-suggestion-popover[data-bayan-theme="light"] .bayan-il-popover-dismiss {
|
| 1030 |
+
border-color: rgba(26, 29, 33, 0.18) !important;
|
| 1031 |
+
color: #5A6472 !important;
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
.bayan-il-suggestion-popover[data-bayan-theme="light"] .bayan-il-popover-dismiss:hover {
|
| 1035 |
+
color: #C53030 !important;
|
| 1036 |
+
border-color: #C53030 !important;
|
| 1037 |
+
background: rgba(197, 48, 48, 0.06) !important;
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
.bayan-il-suggestion-popover[data-bayan-theme="light"] .bayan-il-popover-hint {
|
| 1041 |
+
color: #5A6472 !important;
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
/* Modal Panel UI Sync */
|
| 1045 |
.bayan-il-sidebar-panel {
|
| 1046 |
display: flex !important;
|
|
|
|
| 1208 |
opacity: 0.9 !important;
|
| 1209 |
}
|
| 1210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1211 |
|
extension/content-inline.js
CHANGED
|
@@ -462,7 +462,7 @@
|
|
| 462 |
if (!original || !correction) return;
|
| 463 |
|
| 464 |
const suggestion = { id: sid, original, correction, type, start, end };
|
| 465 |
-
const typeLabels = { spelling: 'إملائي', grammar: 'نحوي', punctuation: 'ترقيم' };
|
| 466 |
|
| 467 |
tooltip = document.createElement('div');
|
| 468 |
tooltip.setAttribute('data-bayan-theme', currentBayanTheme);
|
|
@@ -471,23 +471,22 @@
|
|
| 471 |
|
| 472 |
const typeLabel = typeLabels[type] || type;
|
| 473 |
const typeClass = type === 'spelling' ? 'bayan-il-popover-type--spelling' : type === 'grammar' ? 'bayan-il-popover-type--grammar' : 'bayan-il-popover-type--punctuation';
|
| 474 |
-
|
| 475 |
-
|
| 476 |
safeHTML(tooltip, `
|
| 477 |
-
<div class="bayan-il-popover-type ${typeClass}">
|
| 478 |
-
<span class="bayan-il-popover-type-icon">${icon}</span> ${typeLabel}
|
| 479 |
-
</div>
|
| 480 |
<div class="bayan-il-popover-original-word">
|
| 481 |
<span class="bayan-il-popover-label">الكلمة:</span>
|
| 482 |
<span class="bayan-il-tooltip-original">${esc(original)}</span>
|
|
|
|
|
|
|
| 483 |
</div>
|
| 484 |
<div class="bayan-il-popover-alternatives">
|
| 485 |
<button class="bayan-il-popover-alt-btn bayan-il-popover-alt-main" data-action="apply">
|
| 486 |
-
${correction ? esc(correction) : '<s style="opacity:0.5">حذف</s>'}
|
| 487 |
</button>
|
| 488 |
</div>
|
| 489 |
<button type="button" class="bayan-il-popover-dismiss" data-action="ignore" title="تجاهل هذا الاقتراح">تجاهل</button>
|
| 490 |
-
<p class="bayan-il-popover-hint">اختر التصحيح المناسب</p>
|
| 491 |
`);
|
| 492 |
|
| 493 |
document.body.appendChild(tooltip);
|
|
@@ -525,6 +524,11 @@
|
|
| 525 |
});
|
| 526 |
|
| 527 |
setTimeout(() => document.addEventListener('click', outsideClick, { once: true }), 100);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
}
|
| 529 |
|
| 530 |
function outsideClick(e) {
|
|
@@ -1135,14 +1139,22 @@
|
|
| 1135 |
safeHTML(modalPanel, `
|
| 1136 |
<div class="bayan-il-modal-top-bar" id="bayan-modal-drag-handle">
|
| 1137 |
<div class="bayan-il-modal-brand">
|
| 1138 |
-
<img src="${chrome.runtime.getURL('assets/icons/
|
| 1139 |
<div class="bayan-il-header-divider"></div>
|
| 1140 |
<span class="bayan-il-modal-title">بيان</span>
|
| 1141 |
</div>
|
| 1142 |
-
<button class="bayan-il-modal-close" title="إغلاق">
|
| 1143 |
</div>
|
| 1144 |
|
| 1145 |
<div class="bayan-il-modal-body-scroll">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1146 |
<div class="bayan-il-modal-score-card">
|
| 1147 |
<h3 class="bayan-il-modal-section-title">تقييم الكتابة</h3>
|
| 1148 |
<div class="bayan-il-modal-score-ring" role="img" aria-label="تقييم الكتابة">
|
|
@@ -1160,14 +1172,6 @@
|
|
| 1160 |
<span class="bayan-il-modal-count bayan-il-modal-count--punctuation"><strong id="bayan-modal-count-punctuation">٠</strong> ترقيم</span>
|
| 1161 |
</div>
|
| 1162 |
</div>
|
| 1163 |
-
|
| 1164 |
-
<div class="bayan-il-modal-sugg-card">
|
| 1165 |
-
<div class="bayan-il-modal-sugg-header">
|
| 1166 |
-
<h3 class="bayan-il-modal-section-title">الاقتراحات (<span id="bayan-modal-sugg-count">٠</span>)</h3>
|
| 1167 |
-
<button id="bayan-modal-apply-all" class="bayan-il-modal-apply-all" style="display:none;" type="button">تطبيق الكل</button>
|
| 1168 |
-
</div>
|
| 1169 |
-
<div id="bayan-modal-cards" class="bayan-il-modal-cards" role="list" aria-live="polite" aria-label="اقتراحات التصحيح"></div>
|
| 1170 |
-
</div>
|
| 1171 |
</div>
|
| 1172 |
`);
|
| 1173 |
|
|
|
|
| 462 |
if (!original || !correction) return;
|
| 463 |
|
| 464 |
const suggestion = { id: sid, original, correction, type, start, end };
|
| 465 |
+
const typeLabels = { spelling: 'خطأ إملائي', grammar: 'خطأ نحوي', punctuation: 'علامات ترقيم' };
|
| 466 |
|
| 467 |
tooltip = document.createElement('div');
|
| 468 |
tooltip.setAttribute('data-bayan-theme', currentBayanTheme);
|
|
|
|
| 471 |
|
| 472 |
const typeLabel = typeLabels[type] || type;
|
| 473 |
const typeClass = type === 'spelling' ? 'bayan-il-popover-type--spelling' : type === 'grammar' ? 'bayan-il-popover-type--grammar' : 'bayan-il-popover-type--punctuation';
|
| 474 |
+
|
|
|
|
| 475 |
safeHTML(tooltip, `
|
| 476 |
+
<div class="bayan-il-popover-type ${typeClass}">${typeLabel}</div>
|
|
|
|
|
|
|
| 477 |
<div class="bayan-il-popover-original-word">
|
| 478 |
<span class="bayan-il-popover-label">الكلمة:</span>
|
| 479 |
<span class="bayan-il-tooltip-original">${esc(original)}</span>
|
| 480 |
+
<span class="bayan-il-popover-arrow">←</span>
|
| 481 |
+
<span class="bayan-il-popover-correction">${correction ? esc(correction) : '<s style="opacity:0.5">حذف</s>'}</span>
|
| 482 |
</div>
|
| 483 |
<div class="bayan-il-popover-alternatives">
|
| 484 |
<button class="bayan-il-popover-alt-btn bayan-il-popover-alt-main" data-action="apply">
|
| 485 |
+
${correction ? '✓ ' + esc(correction) : '<s style="opacity:0.5">حذف</s>'}
|
| 486 |
</button>
|
| 487 |
</div>
|
| 488 |
<button type="button" class="bayan-il-popover-dismiss" data-action="ignore" title="تجاهل هذا الاقتراح">تجاهل</button>
|
| 489 |
+
<p class="bayan-il-popover-hint">اختر التصحيح المناسب · Escape للإغلاق</p>
|
| 490 |
`);
|
| 491 |
|
| 492 |
document.body.appendChild(tooltip);
|
|
|
|
| 524 |
});
|
| 525 |
|
| 526 |
setTimeout(() => document.addEventListener('click', outsideClick, { once: true }), 100);
|
| 527 |
+
|
| 528 |
+
const escHandler = (e) => {
|
| 529 |
+
if (e.key === 'Escape') { hideTooltip(); document.removeEventListener('keydown', escHandler); }
|
| 530 |
+
};
|
| 531 |
+
document.addEventListener('keydown', escHandler);
|
| 532 |
}
|
| 533 |
|
| 534 |
function outsideClick(e) {
|
|
|
|
| 1139 |
safeHTML(modalPanel, `
|
| 1140 |
<div class="bayan-il-modal-top-bar" id="bayan-modal-drag-handle">
|
| 1141 |
<div class="bayan-il-modal-brand">
|
| 1142 |
+
<img src="${chrome.runtime.getURL('assets/icons/icon48.png')}" alt="بيان" style="width: 28px; height: 28px; object-fit: contain; border-radius: 6px;" draggable="false" />
|
| 1143 |
<div class="bayan-il-header-divider"></div>
|
| 1144 |
<span class="bayan-il-modal-title">بيان</span>
|
| 1145 |
</div>
|
| 1146 |
+
<button class="bayan-il-modal-close" title="إغلاق"><svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg></button>
|
| 1147 |
</div>
|
| 1148 |
|
| 1149 |
<div class="bayan-il-modal-body-scroll">
|
| 1150 |
+
<div class="bayan-il-modal-sugg-card">
|
| 1151 |
+
<div class="bayan-il-modal-sugg-header">
|
| 1152 |
+
<h3 class="bayan-il-modal-section-title">الاقتراحات (<span id="bayan-modal-sugg-count">٠</span>)</h3>
|
| 1153 |
+
<button id="bayan-modal-apply-all" class="bayan-il-modal-apply-all" style="display:none;" type="button">تطبيق الكل</button>
|
| 1154 |
+
</div>
|
| 1155 |
+
<div id="bayan-modal-cards" class="bayan-il-modal-cards" role="list" aria-live="polite" aria-label="اقتراحات التصحيح"></div>
|
| 1156 |
+
</div>
|
| 1157 |
+
|
| 1158 |
<div class="bayan-il-modal-score-card">
|
| 1159 |
<h3 class="bayan-il-modal-section-title">تقييم الكتابة</h3>
|
| 1160 |
<div class="bayan-il-modal-score-ring" role="img" aria-label="تقييم الكتابة">
|
|
|
|
| 1172 |
<span class="bayan-il-modal-count bayan-il-modal-count--punctuation"><strong id="bayan-modal-count-punctuation">٠</strong> ترقيم</span>
|
| 1173 |
</div>
|
| 1174 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1175 |
</div>
|
| 1176 |
`);
|
| 1177 |
|
old2.py
ADDED
|
@@ -0,0 +1,1663 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AraSpell — Arabic Spell Checker Pipeline (Rules & Classes)
|
| 2 |
+
# Extracted from AraSpell.py — NO global model loading, NO Gradio dependencies.
|
| 3 |
+
# All classes are imported by araspell_service.py.
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
import math
|
| 7 |
+
import logging
|
| 8 |
+
import torch
|
| 9 |
+
from collections import Counter
|
| 10 |
+
from enum import Enum
|
| 11 |
+
from typing import List, Tuple, Optional
|
| 12 |
+
|
| 13 |
+
import Levenshtein
|
| 14 |
+
import jellyfish
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 19 |
+
# ERROR TYPE ENUM
|
| 20 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
class ErrorType(Enum):
|
| 23 |
+
"""Types of spelling errors"""
|
| 24 |
+
CHAR_REPETITION = "char_repetition"
|
| 25 |
+
WORD_MERGE = "word_merge"
|
| 26 |
+
CHAR_SUBSTITUTION = "char_substitution"
|
| 27 |
+
MIXED = "mixed"
|
| 28 |
+
CLEAN = "clean"
|
| 29 |
+
|
| 30 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 31 |
+
# KEYBOARD PROXIMITY (Phase 12 — from original AraSpell.py L475-520)
|
| 32 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 33 |
+
|
| 34 |
+
class RulesBasedCorrector:
|
| 35 |
+
"""Arabic keyboard-proximity and character substitution rules."""
|
| 36 |
+
|
| 37 |
+
# Arabic keyboard layout adjacency mapping
|
| 38 |
+
KEYBOARD_NEIGHBORS = {
|
| 39 |
+
'ض': ['ص', 'ق'],
|
| 40 |
+
'ص': ['ض', 'ث', 'ق'],
|
| 41 |
+
'ث': ['ص', 'ق'],
|
| 42 |
+
'ق': ['ض', 'ص', 'ث', 'ف', 'غ'],
|
| 43 |
+
'ف': ['ق', 'غ', 'ع', 'ب'],
|
| 44 |
+
'غ': ['ق', 'ف', 'ع', 'ه'],
|
| 45 |
+
'ع': ['ف', 'غ', 'ه', 'خ'],
|
| 46 |
+
'ه': ['غ', 'ع', 'خ', 'ح'],
|
| 47 |
+
'خ': ['ع', 'ه', 'ح', 'ج'],
|
| 48 |
+
'ح': ['ه', 'خ', 'ج'],
|
| 49 |
+
'ج': ['خ', 'ح', 'د'],
|
| 50 |
+
'د': ['ج', 'ذ'],
|
| 51 |
+
'ذ': ['د'],
|
| 52 |
+
'ش': ['س', 'ي', 'ئ'],
|
| 53 |
+
'س': ['ش', 'ي', 'ب'],
|
| 54 |
+
'ي': ['ش', 'س', 'ب', 'ت'],
|
| 55 |
+
'ب': ['ي', 'س', 'ف', 'ل', 'ن'],
|
| 56 |
+
'ل': ['ب', 'ا', 'ن', 'م'],
|
| 57 |
+
'ا': ['ل', 'ت', 'م'],
|
| 58 |
+
'ت': ['ي', 'ا', 'ن'],
|
| 59 |
+
'ن': ['ب', 'ل', 'ت', 'م', 'ك'],
|
| 60 |
+
'م': ['ل', 'ا', 'ن', 'ك'],
|
| 61 |
+
'ك': ['ن', 'م', 'ط'],
|
| 62 |
+
'ط': ['ك', 'ظ'],
|
| 63 |
+
'ظ': ['ط'],
|
| 64 |
+
'ئ': ['ش', 'ء', 'ر'],
|
| 65 |
+
'ء': ['ئ', 'ؤ'],
|
| 66 |
+
'ؤ': ['ء', 'ر'],
|
| 67 |
+
'ر': ['ئ', 'ؤ', 'لا', 'ى', 'ز'],
|
| 68 |
+
'لا': ['ر', 'ى'],
|
| 69 |
+
'ى': ['ر', 'لا', 'ة', 'ز'],
|
| 70 |
+
'ة': ['ى', 'و', 'ز'],
|
| 71 |
+
'و': ['ة', 'ز'],
|
| 72 |
+
'ز': ['ر', 'ى', 'ة', 'و'],
|
| 73 |
+
'أ': ['ا', 'إ', 'آ'],
|
| 74 |
+
'إ': ['ا', 'أ'],
|
| 75 |
+
'آ': ['ا', 'أ'],
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
@staticmethod
|
| 79 |
+
def is_keyboard_neighbor(char1: str, char2: str) -> bool:
|
| 80 |
+
"""Check if two Arabic chars are adjacent on the keyboard."""
|
| 81 |
+
neighbors = RulesBasedCorrector.KEYBOARD_NEIGHBORS.get(char1, [])
|
| 82 |
+
return char2 in neighbors
|
| 83 |
+
|
| 84 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 85 |
+
# POST PROCESSOR
|
| 86 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 87 |
+
|
| 88 |
+
class AraSpellPostProcessor:
|
| 89 |
+
"""Arabic text post-processing techniques."""
|
| 90 |
+
|
| 91 |
+
ARABIC_HARAKAT = 'ًٌٍَُِّْ'
|
| 92 |
+
TATWEEL = 'ـ'
|
| 93 |
+
NORMALIZER_MAP = {
|
| 94 |
+
'ﻹ': 'لإ', 'ﻷ': 'لأ', 'ﻵ': 'لآ', 'ﻻ': 'لا', 'ﷲ': 'الله'
|
| 95 |
+
}
|
| 96 |
+
ARABIC_CONSONANTS = set('بتثجحخدذرزسشصضطظعغفقكلمن')
|
| 97 |
+
|
| 98 |
+
# --- Basic Normalization ---
|
| 99 |
+
|
| 100 |
+
@staticmethod
|
| 101 |
+
def remove_harakat(text: str) -> str:
|
| 102 |
+
"""Remove Arabic diacritics"""
|
| 103 |
+
return re.sub(r'[ً-ْ]', '', text)
|
| 104 |
+
|
| 105 |
+
@staticmethod
|
| 106 |
+
def remove_tatweel(text: str) -> str:
|
| 107 |
+
"""Remove Arabic kashida/tatweel"""
|
| 108 |
+
return text.replace(AraSpellPostProcessor.TATWEEL, '')
|
| 109 |
+
|
| 110 |
+
@staticmethod
|
| 111 |
+
def normalize_special_chars(text: str) -> str:
|
| 112 |
+
"""Normalize special Arabic ligatures"""
|
| 113 |
+
for old, new in AraSpellPostProcessor.NORMALIZER_MAP.items():
|
| 114 |
+
text = text.replace(old, new)
|
| 115 |
+
return text
|
| 116 |
+
|
| 117 |
+
# --- Core Functions ---
|
| 118 |
+
|
| 119 |
+
@staticmethod
|
| 120 |
+
def unified_collapse_repeated(text: str) -> str:
|
| 121 |
+
"""
|
| 122 |
+
Collapse repeated characters.
|
| 123 |
+
Arabic: 3+ consecutive → 1 | Latin: 2+ consecutive → 1
|
| 124 |
+
"""
|
| 125 |
+
text = re.sub(r"([\u0600-\u06FF])\1{2,}", r"\1", text)
|
| 126 |
+
text = re.sub(r"([a-zA-Z])\1+", r"\1", text)
|
| 127 |
+
return text
|
| 128 |
+
|
| 129 |
+
@staticmethod
|
| 130 |
+
def remove_duplicate_words(text: str) -> str:
|
| 131 |
+
"""Remove consecutive duplicate words. e.g. كتاب كتاب → كتاب"""
|
| 132 |
+
words = text.split()
|
| 133 |
+
if len(words) < 2:
|
| 134 |
+
return text
|
| 135 |
+
result = [words[0]]
|
| 136 |
+
for i in range(1, len(words)):
|
| 137 |
+
if words[i] != words[i-1]:
|
| 138 |
+
result.append(words[i])
|
| 139 |
+
return ' '.join(result)
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
def normalize_spaces(text: str) -> str:
|
| 143 |
+
"""Normalize whitespace: multiple spaces, unicode spaces, punctuation spacing."""
|
| 144 |
+
text = re.sub(r' +', ' ', text)
|
| 145 |
+
text = text.replace('\u00A0', ' ')
|
| 146 |
+
text = text.replace('\u200B', '')
|
| 147 |
+
text = text.replace('\u200C', '')
|
| 148 |
+
text = text.replace('\u200D', '')
|
| 149 |
+
text = text.strip()
|
| 150 |
+
text = re.sub(r'\s*([،؛؟!.])\s*', r'\1 ', text)
|
| 151 |
+
text = text.strip()
|
| 152 |
+
return text
|
| 153 |
+
|
| 154 |
+
@staticmethod
|
| 155 |
+
def remove_word_repetition_with_wa(text: str) -> str:
|
| 156 |
+
"""Remove word و word → word"""
|
| 157 |
+
words = text.split()
|
| 158 |
+
result = []
|
| 159 |
+
i = 0
|
| 160 |
+
while i < len(words):
|
| 161 |
+
if i + 2 < len(words) and words[i] == words[i+2] and words[i+1] == 'و':
|
| 162 |
+
result.append(words[i])
|
| 163 |
+
i += 3
|
| 164 |
+
else:
|
| 165 |
+
result.append(words[i])
|
| 166 |
+
i += 1
|
| 167 |
+
return ' '.join(result)
|
| 168 |
+
|
| 169 |
+
# --- Hamza & Ta Marbuta Handling ---
|
| 170 |
+
|
| 171 |
+
# Common Arabic words with hamza errors — covers the most frequent
|
| 172 |
+
# spelling mistakes in informal Arabic writing
|
| 173 |
+
HAMZA_WHITELIST = {
|
| 174 |
+
'الي': 'إلى', 'الى': 'إلى',
|
| 175 |
+
'انت': 'أنت', 'انتم': 'أنتم', 'انتي': 'أنتِ',
|
| 176 |
+
'انتو': 'أنتم', 'انتن': 'أنتن',
|
| 177 |
+
'انا': 'أنا',
|
| 178 |
+
'امس': 'أمس',
|
| 179 |
+
'لان': 'لأن', 'لانه': 'لأنه', 'لانها': 'لأنها',
|
| 180 |
+
'لانهم': 'لأنهم', 'لانك': 'لأنك',
|
| 181 |
+
'اذا': 'إذا', 'اذ': 'إذ',
|
| 182 |
+
'اي': 'أي', 'اين': 'أين',
|
| 183 |
+
'او': 'أو',
|
| 184 |
+
'اما': 'أما',
|
| 185 |
+
'ان': 'أن', 'انه': 'أنه', 'انها': 'أنها', 'انهم': 'أنهم',
|
| 186 |
+
'اخر': 'آخر', 'اخرى': 'أخرى',
|
| 187 |
+
'الان': 'الآن',
|
| 188 |
+
'اول': 'أول', 'اولى': 'أولى',
|
| 189 |
+
'اصبح': 'أصبح', 'اصبحت': 'أصبحت',
|
| 190 |
+
'اكثر': 'أكثر', 'اقل': 'أقل',
|
| 191 |
+
'اعلى': 'أعلى', 'ادنى': 'أدنى',
|
| 192 |
+
'اسرع': 'أسرع', 'ابطا': 'أبطأ',
|
| 193 |
+
'اكبر': 'أكبر', 'اصغر': 'أصغر',
|
| 194 |
+
'احسن': 'أحسن', 'اسوا': 'أسوأ',
|
| 195 |
+
'امام': 'أمام',
|
| 196 |
+
'اثناء': 'أثناء',
|
| 197 |
+
'ايضا': 'أيضاً', 'ايض': 'أيضاً',
|
| 198 |
+
'اساسي': 'أساسي', 'اساسية': 'أساسية',
|
| 199 |
+
'اخي': 'أخي', 'اخت': 'أخت', 'اخو': 'أخو',
|
| 200 |
+
'ابي': 'أبي', 'اب': 'أب', 'ابو': 'أبو',
|
| 201 |
+
'اهل': 'أهل',
|
| 202 |
+
'اطفال': 'أطفال',
|
| 203 |
+
'اصدقاء': 'أصدقاء', 'اصدقائي': 'أصدقائي',
|
| 204 |
+
'اعتقد': 'أعتقد', 'اريد': 'أريد', 'احب': 'أحب',
|
| 205 |
+
'اعرف': 'أعرف', 'اعلم': 'أعلم',
|
| 206 |
+
'اخذ': 'أخذ', 'اكل': 'أكل',
|
| 207 |
+
'الايام': 'الأيام',
|
| 208 |
+
'الاطفال': 'الأطفال',
|
| 209 |
+
'الاسعار': 'الأسعار',
|
| 210 |
+
'الاولى': 'الأولى',
|
| 211 |
+
'الاخير': 'الأخير', 'الاخيرة': 'الأخيرة',
|
| 212 |
+
'واصدقائي': 'وأصدقائي',
|
| 213 |
+
# FIX-14: Additional hamza entries
|
| 214 |
+
'ابناء': 'أبناء',
|
| 215 |
+
'اجمل': 'أجمل', 'اجمع': 'أجمع',
|
| 216 |
+
'اعلن': 'أعلن', 'اعلنت': 'أعلنت',
|
| 217 |
+
'اكد': 'أكد', 'اكدت': 'أكدت',
|
| 218 |
+
'اشار': 'أشار', 'اشارت': 'أشارت',
|
| 219 |
+
'ارسل': 'أرسل', 'ارسلت': 'أرسلت',
|
| 220 |
+
'اضاف': 'أضاف', 'اضافت': 'أضافت',
|
| 221 |
+
'اخيرا': 'أخيراً', 'اخيراً': 'أخيراً',
|
| 222 |
+
'اساسا': 'أساساً', 'اساساً': 'أساساً',
|
| 223 |
+
'احيانا': 'أحياناً', 'احياناً': 'أحياناً',
|
| 224 |
+
'ابدا': 'أبداً', 'ابداً': 'أبداً',
|
| 225 |
+
'اصلا': 'أصلاً', 'اصلاً': 'أصلاً',
|
| 226 |
+
'اخبار': 'أخبار', 'اخبر': 'أخبر',
|
| 227 |
+
'امر': 'أمر', 'امور': 'أمور',
|
| 228 |
+
'اهم': 'أهم', 'اهمية': 'أهمية',
|
| 229 |
+
'اصبح': 'أصبح', 'اصل': 'أصل',
|
| 230 |
+
'اثر': 'أثر', 'اثار': 'آثار',
|
| 231 |
+
'اساء': 'أساء', 'اساس': 'أساس',
|
| 232 |
+
'استاذ': 'أستاذ', 'اسلام': 'إسلام',
|
| 233 |
+
# Batch 3: More hamza entries for remaining FN cases
|
| 234 |
+
'اسرة': 'أسرة', 'اسر': 'أسر',
|
| 235 |
+
'اعضاء': 'أعضاء', 'اعداد': 'أعداد',
|
| 236 |
+
'اعمال': 'أعمال', 'اعمار': 'أعمار',
|
| 237 |
+
'انجاز': 'إنجاز', 'انجازات': 'إنجازات',
|
| 238 |
+
'انشاء': 'إنشاء', 'انتاج': 'إنتاج',
|
| 239 |
+
'انتخابات': 'انتخابات', 'انتظار': 'انتظار',
|
| 240 |
+
'اسلامي': 'إسلامي', 'اسلامية': 'إسلامية',
|
| 241 |
+
'امكانية': 'إمكانية', 'امكان': 'إمكان',
|
| 242 |
+
'اشكالية': 'إشكالية',
|
| 243 |
+
'ادارة': 'إدارة', 'ادارية': 'إدارية',
|
| 244 |
+
'اعلام': 'إعلام', 'اعلامي': 'إعلامي',
|
| 245 |
+
'احتمال': 'احتمال', 'احتفال': 'احتفال',
|
| 246 |
+
'ازور': 'أزور', 'اذهب': 'أذهب', 'اكتب': 'أكتب',
|
| 247 |
+
'اقرا': 'أقرأ', 'اقرأ': 'أقرأ',
|
| 248 |
+
'اعمل': 'أعمل', 'ادرس': 'أدرس',
|
| 249 |
+
'اشتري': 'أشتري', 'اسافر': 'أسافر',
|
| 250 |
+
'احبه': 'أحبه',
|
| 251 |
+
'مسؤول': 'مسؤول', 'مسؤولية': 'مسؤولية',
|
| 252 |
+
'رؤية': 'رؤية', 'رؤيا': 'رؤيا',
|
| 253 |
+
'مؤسسة': 'مؤسسة', 'مؤتمر': 'مؤتمر',
|
| 254 |
+
'تأثير': 'تأثير', 'تأكيد': 'تأكيد',
|
| 255 |
+
'البنايه': 'البناية',
|
| 256 |
+
'جدا': 'جداً', 'جداً': 'جداً',
|
| 257 |
+
# FIX-14: Alif maqsura common errors
|
| 258 |
+
'المستشفي': 'المستشفى',
|
| 259 |
+
'مصطفي': 'مصطفى', 'موسي': 'موسى', 'عيسي': 'عيسى',
|
| 260 |
+
'هدي': 'هدى', 'بني': 'بنى',
|
| 261 |
+
'معني': 'معنى', 'مبني': 'مبنى',
|
| 262 |
+
'علي': 'على', # Common alif maqsura confusion
|
| 263 |
+
'الي': 'إلى',
|
| 264 |
+
# FIX-47: Verb+pronoun hamza entries (احبه→أحبه)
|
| 265 |
+
'احبه': 'أحبه', 'احبها': 'أحبها', 'احبك': 'أحبك',
|
| 266 |
+
'احبكم': 'أحبكم', 'احببت': 'أحببت',
|
| 267 |
+
'افهم': 'أفهم', 'افهمه': 'أفهمه', 'افهمها': 'أفهمها',
|
| 268 |
+
'افهمك': 'أفهمك',
|
| 269 |
+
'اعطي': 'أعطي', 'اعطاه': 'أعطاه', 'اعطاها': 'أعطاها',
|
| 270 |
+
'اعطى': 'أعطى', 'اعطت': 'أعطت', 'اعطيت': 'أعطيت',
|
| 271 |
+
'احتاج': 'أحتاج', 'احتاجه': 'أحتاجه',
|
| 272 |
+
'استطيع': 'أستطيع', 'استطع': 'أستطع',
|
| 273 |
+
'اتمنى': 'أتمنى', 'اتوقع': 'أتوقع',
|
| 274 |
+
'اشعر': 'أشعر', 'اظن': 'أظن', 'افضل': 'أفضل',
|
| 275 |
+
'اخاف': 'أخاف', 'اتذكر': 'أتذكر', 'اتعلم': 'أتعلم',
|
| 276 |
+
'ارجو': 'أرجو', 'اتوقف': 'أتوقف', 'انصح': 'أنصح',
|
| 277 |
+
'انسان': 'إنسان', 'انسانية': 'إنسانية',
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
@staticmethod
|
| 281 |
+
def fix_hamza_conservative(text: str) -> str:
|
| 282 |
+
"""Conservative Hamza normalization — only at word END, not middle."""
|
| 283 |
+
words = text.split()
|
| 284 |
+
result = []
|
| 285 |
+
for word in words:
|
| 286 |
+
if len(word) >= 3:
|
| 287 |
+
if word.endswith('أ'):
|
| 288 |
+
word = word[:-1] + 'ا'
|
| 289 |
+
if word.endswith('إ'):
|
| 290 |
+
word = word[:-1] + 'ا'
|
| 291 |
+
result.append(word)
|
| 292 |
+
return ' '.join(result)
|
| 293 |
+
|
| 294 |
+
# Attached prefixes that can precede hamza-whitelist words
|
| 295 |
+
# Ordered longest-first so وال is tried before و
|
| 296 |
+
HAMZA_PREFIXES = ['وبال', 'فبال', 'وال', 'بال', 'فال', 'كال', 'ول', 'فل',
|
| 297 |
+
'وب', 'فب', 'وك', 'فك', 'و', 'ف', 'ب', 'ك', 'ل']
|
| 298 |
+
|
| 299 |
+
@staticmethod
|
| 300 |
+
def fix_common_hamza(text: str) -> str:
|
| 301 |
+
"""
|
| 302 |
+
Fix common hamza placement errors using a whitelist.
|
| 303 |
+
Also handles prefixed words: و/ف/ب/ك/ل + whitelist word.
|
| 304 |
+
Handles adjacent punctuation (e.g. واصدقائي، → وأصدقائي،)
|
| 305 |
+
"""
|
| 306 |
+
words = text.split()
|
| 307 |
+
result = []
|
| 308 |
+
for word in words:
|
| 309 |
+
# Separate leading/trailing punctuation from the core word
|
| 310 |
+
match = re.match(r'^([\.,،؛؟!:;?\(\)\[\]«»"\'\s]*)(.*?)([\.,،؛؟!:;?\(\)\[\]«»"\'\s]*)$', word)
|
| 311 |
+
if not match or not match.group(2):
|
| 312 |
+
result.append(word)
|
| 313 |
+
continue
|
| 314 |
+
|
| 315 |
+
lead_punct = match.group(1)
|
| 316 |
+
core_word = match.group(2)
|
| 317 |
+
trail_punct = match.group(3)
|
| 318 |
+
|
| 319 |
+
# Check exact match first
|
| 320 |
+
if core_word in AraSpellPostProcessor.HAMZA_WHITELIST:
|
| 321 |
+
result.append(lead_punct + AraSpellPostProcessor.HAMZA_WHITELIST[core_word] + trail_punct)
|
| 322 |
+
continue
|
| 323 |
+
|
| 324 |
+
# Try stripping common prefixes and looking up the remainder
|
| 325 |
+
fixed = False
|
| 326 |
+
for prefix in AraSpellPostProcessor.HAMZA_PREFIXES:
|
| 327 |
+
if core_word.startswith(prefix) and len(core_word) > len(prefix) + 1:
|
| 328 |
+
remainder = core_word[len(prefix):]
|
| 329 |
+
if remainder in AraSpellPostProcessor.HAMZA_WHITELIST:
|
| 330 |
+
result.append(lead_punct + prefix + AraSpellPostProcessor.HAMZA_WHITELIST[remainder] + trail_punct)
|
| 331 |
+
fixed = True
|
| 332 |
+
break
|
| 333 |
+
if not fixed:
|
| 334 |
+
result.append(word)
|
| 335 |
+
return ' '.join(result)
|
| 336 |
+
|
| 337 |
+
@staticmethod
|
| 338 |
+
def fix_ha_ta_marbuta(text: str, vocab_manager=None) -> str:
|
| 339 |
+
"""
|
| 340 |
+
Smart ه → ة fix at end of words.
|
| 341 |
+
Strategy: Always prefer ة when the previous char is a consonant,
|
| 342 |
+
UNLESS the ه form is specifically a known word and the ة form is NOT.
|
| 343 |
+
"""
|
| 344 |
+
PROTECTED_ENDINGS = ['لله']
|
| 345 |
+
# Words that genuinely end in ه (not ة)
|
| 346 |
+
PROTECTED_HA_WORDS = {
|
| 347 |
+
'الله', 'لله', 'فيه', 'عليه', 'منه', 'به', 'له', 'إليه',
|
| 348 |
+
'وجه', 'نزه', 'سفه', 'فقه', 'نبه', 'شبه', 'مكره', 'تنبه',
|
| 349 |
+
'اتجه', 'توجه', 'تشابه',
|
| 350 |
+
}
|
| 351 |
+
words = text.split()
|
| 352 |
+
result = []
|
| 353 |
+
for word in words:
|
| 354 |
+
if any(word.endswith(e) for e in PROTECTED_ENDINGS):
|
| 355 |
+
result.append(word)
|
| 356 |
+
continue
|
| 357 |
+
if word in PROTECTED_HA_WORDS:
|
| 358 |
+
result.append(word)
|
| 359 |
+
continue
|
| 360 |
+
if len(word) >= 3 and word.endswith('ه'):
|
| 361 |
+
if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS:
|
| 362 |
+
candidate_with_ta = word[:-1] + 'ة'
|
| 363 |
+
# Default: prefer ة (correct Arabic orthography for feminine nouns)
|
| 364 |
+
if vocab_manager:
|
| 365 |
+
ta_iv = vocab_manager.is_iv(candidate_with_ta)
|
| 366 |
+
ha_iv = vocab_manager.is_iv(word)
|
| 367 |
+
if ta_iv:
|
| 368 |
+
# Always prefer ة when it's a valid word
|
| 369 |
+
result.append(candidate_with_ta)
|
| 370 |
+
continue
|
| 371 |
+
elif ha_iv:
|
| 372 |
+
result.append(word)
|
| 373 |
+
continue
|
| 374 |
+
# No vocab manager — default to ة
|
| 375 |
+
result.append(candidate_with_ta)
|
| 376 |
+
continue
|
| 377 |
+
result.append(word)
|
| 378 |
+
return ' '.join(result)
|
| 379 |
+
|
| 380 |
+
# --- Hallucination Removal ---
|
| 381 |
+
|
| 382 |
+
@staticmethod
|
| 383 |
+
def remove_hallucinations(text: str) -> str:
|
| 384 |
+
"""Remove model hallucinations: duplicate words, trailing 'و' artifacts."""
|
| 385 |
+
words = text.split()
|
| 386 |
+
if not words:
|
| 387 |
+
return text
|
| 388 |
+
result = []
|
| 389 |
+
i = 0
|
| 390 |
+
|
| 391 |
+
def normalize_word(w: str) -> str:
|
| 392 |
+
w = w.replace('ال', '').replace('ة', 'ه')
|
| 393 |
+
w = re.sub(r'[أإآ]', 'ا', w)
|
| 394 |
+
return w
|
| 395 |
+
|
| 396 |
+
while i < len(words):
|
| 397 |
+
word = words[i]
|
| 398 |
+
if len(word) > 4 and word.endswith('و'):
|
| 399 |
+
prev_char = word[-2]
|
| 400 |
+
if prev_char in 'ةهاأإآء':
|
| 401 |
+
word = word[:-1]
|
| 402 |
+
if i + 1 < len(words):
|
| 403 |
+
next_word = words[i + 1]
|
| 404 |
+
if normalize_word(word) == normalize_word(next_word):
|
| 405 |
+
keep = next_word if next_word.startswith('ال') and not word.startswith('ال') else word
|
| 406 |
+
result.append(keep)
|
| 407 |
+
i += 2
|
| 408 |
+
continue
|
| 409 |
+
result.append(word)
|
| 410 |
+
i += 1
|
| 411 |
+
return ' '.join(result)
|
| 412 |
+
|
| 413 |
+
@staticmethod
|
| 414 |
+
def remove_hallucinated_prefix(text: str, original: str) -> str:
|
| 415 |
+
"""Remove particles (و/في) added by model if not in original"""
|
| 416 |
+
if not original:
|
| 417 |
+
return text
|
| 418 |
+
if text.startswith('و ') and not original.startswith('و'):
|
| 419 |
+
rest = text[2:].strip()
|
| 420 |
+
if AraSpellPostProcessor.normalize_special_chars(rest) == AraSpellPostProcessor.normalize_special_chars(original):
|
| 421 |
+
return rest
|
| 422 |
+
return text
|
| 423 |
+
|
| 424 |
+
# --- Word Splitting & Merging ---
|
| 425 |
+
|
| 426 |
+
@staticmethod
|
| 427 |
+
def merge_separated_al(text: str) -> str:
|
| 428 |
+
"""Merge 'ال' separated by space: ال + كتاب → الكتاب"""
|
| 429 |
+
return re.sub(r'\bال\s+(\w+)', r'ال\1', text)
|
| 430 |
+
|
| 431 |
+
@staticmethod
|
| 432 |
+
def join_fragments(text: str) -> str:
|
| 433 |
+
"""Join short fragments with validation."""
|
| 434 |
+
words = text.split()
|
| 435 |
+
if len(words) < 2:
|
| 436 |
+
return text
|
| 437 |
+
STANDALONE_WORDS = {
|
| 438 |
+
'من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى', 'حتى', 'منذ', 'خلال',
|
| 439 |
+
'بعد', 'قبل', 'ب', 'ل', 'ك', 'و', 'أو', 'لا', 'ما', 'لم', 'لن',
|
| 440 |
+
'هو', 'هي', 'هم', 'أن', 'إن', 'كل', 'كان', 'قد', 'قال', 'ذلك',
|
| 441 |
+
'هذا', 'هذه', 'تلك', 'التي', 'الذي', 'التى', 'اللذي'
|
| 442 |
+
}
|
| 443 |
+
result = []
|
| 444 |
+
i = 0
|
| 445 |
+
while i < len(words):
|
| 446 |
+
word = words[i]
|
| 447 |
+
if i + 1 < len(words):
|
| 448 |
+
next_word = words[i + 1]
|
| 449 |
+
if word in STANDALONE_WORDS and next_word in STANDALONE_WORDS:
|
| 450 |
+
result.append(word)
|
| 451 |
+
i += 1
|
| 452 |
+
continue
|
| 453 |
+
if len(next_word) == 1:
|
| 454 |
+
result.append(word + next_word)
|
| 455 |
+
i += 2
|
| 456 |
+
continue
|
| 457 |
+
if len(word) >= 2 and len(next_word) >= 2 and word[-1] == next_word[0]:
|
| 458 |
+
if not (word in STANDALONE_WORDS and next_word in STANDALONE_WORDS):
|
| 459 |
+
result.append(word[:-1] + next_word)
|
| 460 |
+
i += 2
|
| 461 |
+
continue
|
| 462 |
+
if (2 <= len(word) <= 4 and
|
| 463 |
+
1 <= len(next_word) <= 2 and
|
| 464 |
+
3 <= len(word) + len(next_word) <= 7):
|
| 465 |
+
if not (word in STANDALONE_WORDS and next_word in STANDALONE_WORDS):
|
| 466 |
+
result.append(word + next_word)
|
| 467 |
+
i += 2
|
| 468 |
+
continue
|
| 469 |
+
result.append(word)
|
| 470 |
+
i += 1
|
| 471 |
+
return ' '.join(result)
|
| 472 |
+
|
| 473 |
+
# --- Main Pipelines ---
|
| 474 |
+
|
| 475 |
+
@staticmethod
|
| 476 |
+
def full_postprocess(text: str, original: str = "", vocab_manager=None) -> str:
|
| 477 |
+
"""Apply all post-processing steps."""
|
| 478 |
+
if original:
|
| 479 |
+
text = AraSpellPostProcessor.remove_hallucinated_prefix(text, original)
|
| 480 |
+
text = AraSpellPostProcessor.normalize_special_chars(text)
|
| 481 |
+
text = AraSpellPostProcessor.remove_hallucinations(text)
|
| 482 |
+
text = AraSpellPostProcessor.unified_collapse_repeated(text)
|
| 483 |
+
text = AraSpellPostProcessor.fix_hamza_conservative(text)
|
| 484 |
+
text = AraSpellPostProcessor.fix_common_hamza(text) # Fix S3: hamza whitelist
|
| 485 |
+
text = AraSpellPostProcessor.fix_ha_ta_marbuta(text, vocab_manager=vocab_manager)
|
| 486 |
+
text = AraSpellPostProcessor.remove_word_repetition_with_wa(text)
|
| 487 |
+
text = AraSpellPostProcessor.remove_duplicate_words(text)
|
| 488 |
+
text = AraSpellPostProcessor.normalize_spaces(text)
|
| 489 |
+
return text
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 493 |
+
# ERROR CLASSIFIER
|
| 494 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 495 |
+
|
| 496 |
+
class ErrorClassifier:
|
| 497 |
+
"""Classify type of spelling error"""
|
| 498 |
+
|
| 499 |
+
NON_ARABIC_KEYBOARD = set('پگچژکەڕڤڵڎےۀۃھیټډڼڑ')
|
| 500 |
+
|
| 501 |
+
@staticmethod
|
| 502 |
+
def has_char_substitution(text: str) -> bool:
|
| 503 |
+
return any(c in ErrorClassifier.NON_ARABIC_KEYBOARD for c in text)
|
| 504 |
+
|
| 505 |
+
@staticmethod
|
| 506 |
+
def has_char_repetition(text: str, threshold: int = 3) -> bool:
|
| 507 |
+
return bool(re.search(r"(.)\1{" + str(threshold - 1) + ",}", text))
|
| 508 |
+
|
| 509 |
+
@staticmethod
|
| 510 |
+
def has_word_merge(text: str, max_word_len: int = 8) -> bool:
|
| 511 |
+
words = text.split()
|
| 512 |
+
if any(len(w) > max_word_len for w in words):
|
| 513 |
+
return True
|
| 514 |
+
if len(words) == 1 and len(text) > 6:
|
| 515 |
+
return True
|
| 516 |
+
return False
|
| 517 |
+
|
| 518 |
+
@staticmethod
|
| 519 |
+
def classify(text: str) -> ErrorType:
|
| 520 |
+
has_rep = ErrorClassifier.has_char_repetition(text)
|
| 521 |
+
has_merge = ErrorClassifier.has_word_merge(text)
|
| 522 |
+
has_sub = ErrorClassifier.has_char_substitution(text)
|
| 523 |
+
error_count = sum([has_rep, has_merge, has_sub])
|
| 524 |
+
if error_count >= 2:
|
| 525 |
+
return ErrorType.MIXED
|
| 526 |
+
elif has_sub:
|
| 527 |
+
return ErrorType.CHAR_SUBSTITUTION
|
| 528 |
+
elif has_rep:
|
| 529 |
+
return ErrorType.CHAR_REPETITION
|
| 530 |
+
elif has_merge:
|
| 531 |
+
return ErrorType.WORD_MERGE
|
| 532 |
+
else:
|
| 533 |
+
return ErrorType.CLEAN
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 537 |
+
# RULES-BASED CORRECTOR
|
| 538 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 539 |
+
|
| 540 |
+
class RulesBasedCorrector:
|
| 541 |
+
"""Rules-based correction with keyboard proximity mapping."""
|
| 542 |
+
|
| 543 |
+
SUBSTITUTION_MAP = {
|
| 544 |
+
'ک': 'ك', 'ی': 'ي', 'ے': 'ي',
|
| 545 |
+
'پ': 'ب', 'چ': 'ج', 'ژ': 'ز',
|
| 546 |
+
'گ': 'ك', 'ڤ': 'ف', 'ڵ': 'ل',
|
| 547 |
+
'ڕ': 'ر', 'ڎ': 'د', 'ڼ': 'ن',
|
| 548 |
+
'ټ': 'ت', 'ډ': 'د', 'ړ': 'ر',
|
| 549 |
+
'ۀ': 'ه', 'ۃ': 'ة', 'ھ': 'ه',
|
| 550 |
+
'ە': 'ه', 'ڑ': 'ر'
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
PREPOSITIONS = {
|
| 554 |
+
'من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى',
|
| 555 |
+
'حتى', 'منذ', 'خلال', 'بعد', 'قبل',
|
| 556 |
+
'ب', 'ل', 'ك', 'لل'
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
KEYBOARD_NEIGHBORS = {
|
| 560 |
+
'ض': ['ص', 'ق'], 'ص': ['ض', 'ث', 'ق'], 'ث': ['ص', 'ق'],
|
| 561 |
+
'ق': ['ض', 'ص', 'ث', 'ف', 'غ'], 'ف': ['ق', 'غ', 'ع', 'ب'],
|
| 562 |
+
'غ': ['ق', 'ف', 'ع', 'ه'], 'ع': ['ف', 'غ', 'ه', 'خ'],
|
| 563 |
+
'ه': ['غ', 'ع', 'خ', 'ح'], 'خ': ['ع', 'ه', 'ح', 'ج'],
|
| 564 |
+
'ح': ['ه', 'خ', 'ج'], 'ج': ['خ', 'ح', 'د'],
|
| 565 |
+
'د': ['ج', 'ذ'], 'ذ': ['د'],
|
| 566 |
+
'ش': ['س', 'ي', 'ئ'], 'س': ['ش', 'ي', 'ب'],
|
| 567 |
+
'ي': ['ش', 'س', 'ب', 'ت'], 'ب': ['ي', 'س', 'ف', 'ل', 'ن'],
|
| 568 |
+
'ل': ['ب', 'ا', 'ن', 'م'], 'ا': ['ل', 'ت', 'م'],
|
| 569 |
+
'ت': ['ي', 'ا', 'ن'], 'ن': ['ب', 'ل', 'ت', 'م', 'ك'],
|
| 570 |
+
'م': ['ل', 'ا', 'ن', 'ك'], 'ك': ['ن', 'م', 'ط'],
|
| 571 |
+
'ط': ['ك', 'ظ'], 'ظ': ['ط'],
|
| 572 |
+
'ئ': ['ش', 'ء', 'ر'], 'ء': ['ئ', 'ؤ'], 'ؤ': ['ء', 'ر'],
|
| 573 |
+
'ر': ['ئ', 'ؤ', 'لا', 'ى', 'ز'], 'لا': ['ر', 'ى'],
|
| 574 |
+
'ى': ['ر', 'لا', 'ة', 'ز'], 'ة': ['ى', 'و', 'ز'],
|
| 575 |
+
'و': ['ة', 'ز'], 'ز': ['ر', 'ى', 'ة', 'و'],
|
| 576 |
+
'أ': ['ا', 'إ', 'آ'], 'إ': ['ا', 'أ'], 'آ': ['ا', 'أ'],
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
@staticmethod
|
| 580 |
+
def is_keyboard_neighbor(char1: str, char2: str) -> bool:
|
| 581 |
+
neighbors = RulesBasedCorrector.KEYBOARD_NEIGHBORS.get(char1, [])
|
| 582 |
+
return char2 in neighbors
|
| 583 |
+
|
| 584 |
+
@staticmethod
|
| 585 |
+
def fix_char_substitution(text: str) -> str:
|
| 586 |
+
for old, new in RulesBasedCorrector.SUBSTITUTION_MAP.items():
|
| 587 |
+
text = text.replace(old, new)
|
| 588 |
+
return text
|
| 589 |
+
|
| 590 |
+
@staticmethod
|
| 591 |
+
def fix_char_repetition(text: str) -> str:
|
| 592 |
+
text = re.sub(r'([^\d\s])\1{2,}', r'\1', text)
|
| 593 |
+
return text
|
| 594 |
+
|
| 595 |
+
@staticmethod
|
| 596 |
+
def advanced_heuristic_repair(text: str) -> str:
|
| 597 |
+
text = RulesBasedCorrector.fix_char_substitution(text)
|
| 598 |
+
text = RulesBasedCorrector.fix_char_repetition(text)
|
| 599 |
+
words = text.split()
|
| 600 |
+
processed_words = []
|
| 601 |
+
for word in words:
|
| 602 |
+
processed_words.append(RulesBasedCorrector._recursive_split(word))
|
| 603 |
+
return ' '.join(processed_words)
|
| 604 |
+
|
| 605 |
+
@staticmethod
|
| 606 |
+
def _recursive_split(word: str) -> str:
|
| 607 |
+
if len(word) < 4:
|
| 608 |
+
return word
|
| 609 |
+
separables = sorted(['من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى', 'حتى', 'منذ', 'خلال', 'بعد', 'قبل'], key=len, reverse=True)
|
| 610 |
+
for sep in separables:
|
| 611 |
+
if word == sep:
|
| 612 |
+
return word
|
| 613 |
+
if word.startswith(sep):
|
| 614 |
+
remainder = word[len(sep):]
|
| 615 |
+
if len(remainder) >= 3:
|
| 616 |
+
return sep + " " + RulesBasedCorrector._recursive_split(remainder)
|
| 617 |
+
if word.startswith('يا') and len(word) > 4:
|
| 618 |
+
return 'يا ' + RulesBasedCorrector._recursive_split(word[2:])
|
| 619 |
+
return word
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 623 |
+
# OUTPUT VALIDATOR (Hallucination Prevention)
|
| 624 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 625 |
+
|
| 626 |
+
class OutputValidator:
|
| 627 |
+
"""Validate model outputs to prevent hallucinations"""
|
| 628 |
+
|
| 629 |
+
@staticmethod
|
| 630 |
+
def calculate_edit_distance(s1: str, s2: str) -> int:
|
| 631 |
+
return Levenshtein.distance(s1, s2)
|
| 632 |
+
|
| 633 |
+
@staticmethod
|
| 634 |
+
def check_character_preservation(original: str, corrected: str) -> Tuple[bool, str]:
|
| 635 |
+
chars_original = set(original)
|
| 636 |
+
chars_corrected = set(corrected)
|
| 637 |
+
if not chars_original:
|
| 638 |
+
return True, "valid"
|
| 639 |
+
intersection = chars_original & chars_corrected
|
| 640 |
+
union = chars_original | chars_corrected
|
| 641 |
+
jaccard = len(intersection) / len(union) if union else 0
|
| 642 |
+
if jaccard < 0.35:
|
| 643 |
+
return False, "low_character_similarity"
|
| 644 |
+
return True, "valid"
|
| 645 |
+
|
| 646 |
+
@staticmethod
|
| 647 |
+
def check_word_count(original: str, corrected: str) -> Tuple[bool, str]:
|
| 648 |
+
len_orig = len(original.split())
|
| 649 |
+
len_corr = len(corrected.split())
|
| 650 |
+
if len_orig == 1:
|
| 651 |
+
if len_corr <= 3:
|
| 652 |
+
return True, "valid"
|
| 653 |
+
if len(original) > 12 and len_corr <= 6:
|
| 654 |
+
return True, "valid"
|
| 655 |
+
ratio = len_corr / len_orig if len_orig > 0 else 0
|
| 656 |
+
if ratio > 2.0 or ratio < 0.5:
|
| 657 |
+
return False, "word_count_mismatch"
|
| 658 |
+
return True, "valid"
|
| 659 |
+
|
| 660 |
+
def validate(self, original: str, corrected: str, error_type: str) -> Tuple[bool, str]:
|
| 661 |
+
if not corrected or not corrected.strip():
|
| 662 |
+
return False, "empty_output"
|
| 663 |
+
original_no_space = original.replace(' ', '').replace('\u200c', '')
|
| 664 |
+
corrected_no_space = corrected.replace(' ', '').replace('\u200c', '')
|
| 665 |
+
if original_no_space == corrected_no_space:
|
| 666 |
+
return True, "space_leniency_accept"
|
| 667 |
+
len_orig = len(original)
|
| 668 |
+
len_corr = len(corrected)
|
| 669 |
+
if len_corr > len_orig * 2.5:
|
| 670 |
+
return False, "too_long"
|
| 671 |
+
if len_corr < len_orig * 0.5:
|
| 672 |
+
if error_type == ErrorType.CHAR_REPETITION:
|
| 673 |
+
pass
|
| 674 |
+
else:
|
| 675 |
+
return False, "too_short"
|
| 676 |
+
is_valid_count, reason = self.check_word_count(original, corrected)
|
| 677 |
+
if not is_valid_count:
|
| 678 |
+
return False, reason
|
| 679 |
+
is_valid_chars, reason = self.check_character_preservation(original, corrected)
|
| 680 |
+
if not is_valid_chars:
|
| 681 |
+
return False, reason
|
| 682 |
+
return True, "valid"
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 686 |
+
# VOCABULARY MANAGER
|
| 687 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 688 |
+
|
| 689 |
+
class VocabularyManager:
|
| 690 |
+
"""Centralized vocabulary management for OOV/IV detection using CamelTools."""
|
| 691 |
+
|
| 692 |
+
def __init__(self, tokenizer):
|
| 693 |
+
self.tokenizer = tokenizer
|
| 694 |
+
from camel_tools.morphology.database import MorphologyDB
|
| 695 |
+
from camel_tools.morphology.analyzer import Analyzer
|
| 696 |
+
self._db = MorphologyDB.builtin_db()
|
| 697 |
+
self.analyzer = Analyzer(self._db)
|
| 698 |
+
logger.info("VocabularyManager initialized with CamelTools Analyzer")
|
| 699 |
+
|
| 700 |
+
def is_iv(self, word: str) -> bool:
|
| 701 |
+
clean = re.sub(r'[^\w]', '', word)
|
| 702 |
+
if not clean:
|
| 703 |
+
return True
|
| 704 |
+
return len(self.analyzer.analyze(clean)) > 0
|
| 705 |
+
|
| 706 |
+
def is_oov(self, word: str) -> bool:
|
| 707 |
+
return not self.is_iv(word)
|
| 708 |
+
|
| 709 |
+
def get_frequency_rank(self, word: str) -> int:
|
| 710 |
+
clean = re.sub(r'[^\w]', '', word)
|
| 711 |
+
return self.vocab_rank.get(clean, 999999)
|
| 712 |
+
|
| 713 |
+
def all_words_iv(self, text: str) -> bool:
|
| 714 |
+
words = text.split()
|
| 715 |
+
return all(self.is_iv(w) for w in words)
|
| 716 |
+
|
| 717 |
+
def count_oov_words(self, text: str) -> int:
|
| 718 |
+
words = text.split()
|
| 719 |
+
return sum(1 for w in words if self.is_oov(w))
|
| 720 |
+
|
| 721 |
+
def get_oov_words(self, text: str) -> List[str]:
|
| 722 |
+
words = text.split()
|
| 723 |
+
return [w for w in words if self.is_oov(w)]
|
| 724 |
+
|
| 725 |
+
def words_are_equivalent(self, word1: str, word2: str) -> bool:
|
| 726 |
+
norm1 = self.normalize_for_comparison(word1)
|
| 727 |
+
norm2 = self.normalize_for_comparison(word2)
|
| 728 |
+
return norm1 == norm2
|
| 729 |
+
|
| 730 |
+
@staticmethod
|
| 731 |
+
def damerau_levenshtein_distance(s1: str, s2: str) -> int:
|
| 732 |
+
return jellyfish.damerau_levenshtein_distance(s1, s2)
|
| 733 |
+
|
| 734 |
+
def calculate_similarity(self, original: str, corrected: str) -> float:
|
| 735 |
+
dist = self.damerau_levenshtein_distance(original, corrected)
|
| 736 |
+
max_len = max(len(original), len(corrected), 1)
|
| 737 |
+
return 1.0 - (dist / max_len)
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 741 |
+
# WORD ALIGNER
|
| 742 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 743 |
+
|
| 744 |
+
class WordAligner:
|
| 745 |
+
"""Aligns input and output words to create hybrid corrections."""
|
| 746 |
+
|
| 747 |
+
def __init__(self, vocab_manager):
|
| 748 |
+
self.vocab = vocab_manager
|
| 749 |
+
|
| 750 |
+
def align_words(self, input_text: str, output_text: str) -> str:
|
| 751 |
+
input_words = input_text.split()
|
| 752 |
+
output_words = output_text.split()
|
| 753 |
+
if abs(len(input_words) - len(output_words)) > 2:
|
| 754 |
+
input_oov = self.vocab.count_oov_words(input_text)
|
| 755 |
+
output_oov = self.vocab.count_oov_words(output_text)
|
| 756 |
+
return output_text if output_oov < input_oov else input_text
|
| 757 |
+
result = []
|
| 758 |
+
min_len = min(len(input_words), len(output_words))
|
| 759 |
+
for i in range(min_len):
|
| 760 |
+
in_word = input_words[i]
|
| 761 |
+
out_word = output_words[i]
|
| 762 |
+
best_word = self._select_best_word(in_word, out_word)
|
| 763 |
+
result.append(best_word)
|
| 764 |
+
if len(output_words) > min_len:
|
| 765 |
+
result.extend(output_words[min_len:])
|
| 766 |
+
elif len(input_words) > min_len:
|
| 767 |
+
for w in input_words[min_len:]:
|
| 768 |
+
if self.vocab.is_iv(w):
|
| 769 |
+
result.append(w)
|
| 770 |
+
return ' '.join(result)
|
| 771 |
+
|
| 772 |
+
def _select_best_word(self, input_word: str, output_word: str) -> str:
|
| 773 |
+
if input_word == output_word:
|
| 774 |
+
return input_word
|
| 775 |
+
in_iv = self.vocab.is_iv(input_word)
|
| 776 |
+
out_iv = self.vocab.is_iv(output_word)
|
| 777 |
+
if not in_iv and out_iv:
|
| 778 |
+
return output_word
|
| 779 |
+
if in_iv and not out_iv:
|
| 780 |
+
return input_word
|
| 781 |
+
if in_iv and out_iv:
|
| 782 |
+
# Fix S1: When only difference is ه→ة at word end, prefer ة
|
| 783 |
+
# (correct Arabic orthography — ة is the standard feminine ending)
|
| 784 |
+
if (input_word.endswith('ه') and output_word.endswith('ة')
|
| 785 |
+
and input_word[:-1] == output_word[:-1]):
|
| 786 |
+
return output_word
|
| 787 |
+
# Fix S1: Also handle ة→ه (don't regress a correct ة to ه)
|
| 788 |
+
if (input_word.endswith('ة') and output_word.endswith('ه')
|
| 789 |
+
and input_word[:-1] == output_word[:-1]):
|
| 790 |
+
return input_word
|
| 791 |
+
return input_word
|
| 792 |
+
if len(input_word) == len(output_word) and len(input_word) >= 3:
|
| 793 |
+
for i in range(len(input_word)):
|
| 794 |
+
if input_word[i] != output_word[i]:
|
| 795 |
+
hybrid = input_word[:i] + output_word[i] + input_word[i+1:]
|
| 796 |
+
if self.vocab.is_iv(hybrid):
|
| 797 |
+
return hybrid
|
| 798 |
+
hybrid2 = output_word[:i] + input_word[i] + output_word[i+1:]
|
| 799 |
+
if self.vocab.is_iv(hybrid2):
|
| 800 |
+
return hybrid2
|
| 801 |
+
return output_word
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 805 |
+
# SPLIT/MERGE SPECIALIST
|
| 806 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 807 |
+
|
| 808 |
+
class SplitMergeSpecialist:
|
| 809 |
+
"""Handles word splitting and merging with vocabulary validation."""
|
| 810 |
+
|
| 811 |
+
SEPARABLE_PREFIXES = [
|
| 812 |
+
'من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى', 'حتى', 'منذ', 'خلال',
|
| 813 |
+
'بعد', 'قبل', 'بين', 'حول', 'تحت', 'فوق', 'أمام', 'وراء', 'دون',
|
| 814 |
+
'أن', 'لن', 'لم', 'قد', 'سوف', 'كي', 'إذا', 'لو', 'مثل', 'غير',
|
| 815 |
+
'يا',
|
| 816 |
+
]
|
| 817 |
+
|
| 818 |
+
PROTECTED_WORDS = {
|
| 819 |
+
'في', 'من', 'على', 'عن', 'مع', 'إلى', 'الى', 'ان', 'أن', 'لا', 'ما', 'هو', 'هي',
|
| 820 |
+
'لم', 'لن', 'قد', 'كل', 'كان', 'ذلك', 'هذا', 'هذه', 'التي', 'الذي', 'بين',
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
ATTACHED_PREFIXES = [
|
| 824 |
+
'وال', 'بال', 'فال', 'كال', 'لل',
|
| 825 |
+
'وب', 'وف', 'ول', 'وك', 'وم', 'ون',
|
| 826 |
+
'فب', 'فل', 'فك', 'فم',
|
| 827 |
+
]
|
| 828 |
+
|
| 829 |
+
PRONOUN_SUFFIXES = {'كم', 'هم', 'ها', 'هن', 'كن', 'نا', 'هما', 'كما', 'تم', 'تن'}
|
| 830 |
+
|
| 831 |
+
def __init__(self, vocab_manager):
|
| 832 |
+
self.vocab = vocab_manager
|
| 833 |
+
self.separable_prefixes = sorted(
|
| 834 |
+
self.SEPARABLE_PREFIXES, key=len, reverse=True
|
| 835 |
+
)
|
| 836 |
+
|
| 837 |
+
def split_word(self, word: str) -> str:
|
| 838 |
+
if len(word) < 5:
|
| 839 |
+
return word
|
| 840 |
+
if self.vocab.is_iv(word):
|
| 841 |
+
return word
|
| 842 |
+
if word in self.PROTECTED_WORDS:
|
| 843 |
+
return word
|
| 844 |
+
for prefix in self.ATTACHED_PREFIXES:
|
| 845 |
+
if word.startswith(prefix):
|
| 846 |
+
remainder = word[len(prefix):]
|
| 847 |
+
if self.vocab.is_iv(remainder):
|
| 848 |
+
return word
|
| 849 |
+
if prefix.endswith('ال') and self.vocab.is_iv(remainder):
|
| 850 |
+
return word
|
| 851 |
+
for prefix in self.separable_prefixes:
|
| 852 |
+
if word.startswith(prefix) and len(word) > len(prefix) + 2:
|
| 853 |
+
remainder = word[len(prefix):]
|
| 854 |
+
if self.vocab.is_iv(remainder):
|
| 855 |
+
return f"{prefix} {remainder}"
|
| 856 |
+
for i in range(3, len(word) - 2):
|
| 857 |
+
left = word[:i]
|
| 858 |
+
right = word[i:]
|
| 859 |
+
if self.vocab.is_iv(left) and self.vocab.is_iv(right):
|
| 860 |
+
return f"{left} {right}"
|
| 861 |
+
return word
|
| 862 |
+
|
| 863 |
+
def merge_fragments(self, text: str) -> str:
|
| 864 |
+
words = text.split()
|
| 865 |
+
if len(words) < 2:
|
| 866 |
+
return text
|
| 867 |
+
result = []
|
| 868 |
+
i = 0
|
| 869 |
+
while i < len(words):
|
| 870 |
+
word = words[i]
|
| 871 |
+
if i + 1 < len(words):
|
| 872 |
+
next_word = words[i + 1]
|
| 873 |
+
merged = word + next_word
|
| 874 |
+
if len(next_word) == 1 and next_word in 'ةهاي':
|
| 875 |
+
if self.vocab.is_iv(merged):
|
| 876 |
+
result.append(merged)
|
| 877 |
+
i += 2
|
| 878 |
+
continue
|
| 879 |
+
if word == 'ال' and len(next_word) >= 2:
|
| 880 |
+
if self.vocab.is_iv(merged):
|
| 881 |
+
result.append(merged)
|
| 882 |
+
i += 2
|
| 883 |
+
continue
|
| 884 |
+
if self.vocab.is_oov(word) and self.vocab.is_oov(next_word):
|
| 885 |
+
if self.vocab.is_iv(merged):
|
| 886 |
+
result.append(merged)
|
| 887 |
+
i += 2
|
| 888 |
+
continue
|
| 889 |
+
if len(word) <= 2 and self.vocab.is_oov(word):
|
| 890 |
+
if self.vocab.is_iv(merged):
|
| 891 |
+
result.append(merged)
|
| 892 |
+
i += 2
|
| 893 |
+
continue
|
| 894 |
+
if next_word in self.PRONOUN_SUFFIXES:
|
| 895 |
+
if self.vocab.is_iv(merged) and not self.vocab.is_iv(word):
|
| 896 |
+
result.append(merged)
|
| 897 |
+
i += 2
|
| 898 |
+
continue
|
| 899 |
+
if len(word) <= 3 and len(next_word) <= 3:
|
| 900 |
+
if len(merged) >= 5 and self.vocab.is_iv(merged):
|
| 901 |
+
result.append(merged)
|
| 902 |
+
i += 2
|
| 903 |
+
continue
|
| 904 |
+
result.append(word)
|
| 905 |
+
i += 1
|
| 906 |
+
return ' '.join(result)
|
| 907 |
+
|
| 908 |
+
def process_text(self, text: str) -> str:
|
| 909 |
+
text = self.merge_fragments(text)
|
| 910 |
+
words = text.split()
|
| 911 |
+
processed = []
|
| 912 |
+
for word in words:
|
| 913 |
+
if self.vocab.is_oov(word) and len(word) >= 4:
|
| 914 |
+
split_result = self.split_word(word)
|
| 915 |
+
processed.append(split_result)
|
| 916 |
+
else:
|
| 917 |
+
processed.append(word)
|
| 918 |
+
return ' '.join(processed)
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 922 |
+
# EDIT DISTANCE CORRECTOR
|
| 923 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 924 |
+
|
| 925 |
+
class EditDistanceCorrector:
|
| 926 |
+
"""Generates candidates based on Levenshtein distance."""
|
| 927 |
+
|
| 928 |
+
def __init__(self, tokenizer):
|
| 929 |
+
self.tokenizer = tokenizer
|
| 930 |
+
self.vocab = {
|
| 931 |
+
w for w in tokenizer.get_vocab().keys()
|
| 932 |
+
if w.isalpha() and not w.startswith('##') and len(w) > 1
|
| 933 |
+
}
|
| 934 |
+
self.vocab_rank = {w: i for w, i in tokenizer.get_vocab().items()}
|
| 935 |
+
|
| 936 |
+
def edits1(self, word):
|
| 937 |
+
letters = 'أابتثجحخدذرزسشصضطظعغفقكلمنهويءآىةئؤ'
|
| 938 |
+
splits = [(word[:i], word[i:]) for i in range(len(word) + 1)]
|
| 939 |
+
deletes = [L + R[1:] for L, R in splits if R]
|
| 940 |
+
transposes = [L + R[1] + R[0] + R[2:] for L, R in splits if len(R)>1]
|
| 941 |
+
replaces = [L + c + R[1:] for L, R in splits if R for c in letters]
|
| 942 |
+
inserts = [L + c + R for L, R in splits for c in letters]
|
| 943 |
+
return set(deletes + transposes + replaces + inserts)
|
| 944 |
+
|
| 945 |
+
def edits2(self, word):
|
| 946 |
+
return (e2 for e1 in self.edits1(word) for e2 in self.edits1(e1))
|
| 947 |
+
|
| 948 |
+
def known(self, words):
|
| 949 |
+
return set(w for w in words if w in self.vocab)
|
| 950 |
+
|
| 951 |
+
def generate_candidate(self, text: str) -> str:
|
| 952 |
+
words = text.split()
|
| 953 |
+
corrected_words = []
|
| 954 |
+
for word in words:
|
| 955 |
+
clean_word = re.sub(r'[^\w]', '', word)
|
| 956 |
+
if clean_word in self.vocab:
|
| 957 |
+
corrected_words.append(word)
|
| 958 |
+
continue
|
| 959 |
+
candidates = self.known(self.edits1(clean_word))
|
| 960 |
+
if not candidates:
|
| 961 |
+
if len(clean_word) < 7:
|
| 962 |
+
candidates = self.known(self.edits2(clean_word))
|
| 963 |
+
if candidates:
|
| 964 |
+
best_candidate = min(candidates, key=lambda w: self.vocab_rank.get(w, 999999))
|
| 965 |
+
corrected_words.append(best_candidate)
|
| 966 |
+
else:
|
| 967 |
+
corrected_words.append(word)
|
| 968 |
+
return ' '.join(corrected_words)
|
| 969 |
+
|
| 970 |
+
|
| 971 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 972 |
+
# CONTEXTUAL CORRECTOR (MLM-based) — Optional, disabled by default to save RAM
|
| 973 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 974 |
+
|
| 975 |
+
class ContextualCorrector:
|
| 976 |
+
"""MLM-based contextual correction for confusion pairs"""
|
| 977 |
+
|
| 978 |
+
CONFUSION_PAIRS = [
|
| 979 |
+
('ض', 'ظ'), ('ذ', 'ز'), ('ث', 'س'), ('ص', 'س'),
|
| 980 |
+
('ط', 'ت'), ('ق', 'ك'), ('ه', 'ة'), ('ا', 'ى'),
|
| 981 |
+
('ت', 'د'), ('د', 'ض'), ('ك', 'ق'), ('غ', 'ق'),
|
| 982 |
+
('ج', 'ش'), ('س', 'ز'), ('ف', 'ب'), ('و', 'و'),
|
| 983 |
+
('ؤ', 'و'), ('ئ', 'ي'), ('ء', 'أ'), ('إ', 'أ'),
|
| 984 |
+
]
|
| 985 |
+
|
| 986 |
+
def __init__(self, model_name: str = 'aubmindlab/bert-base-arabertv02', cache_size: int = 10000):
|
| 987 |
+
from transformers import AutoTokenizer, AutoModelForMaskedLM
|
| 988 |
+
|
| 989 |
+
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 990 |
+
self.model = AutoModelForMaskedLM.from_pretrained(model_name)
|
| 991 |
+
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 992 |
+
self.model = self.model.to(self.device)
|
| 993 |
+
self.model.eval()
|
| 994 |
+
self.confusion_map = self._build_confusion_map()
|
| 995 |
+
self.cache_hits = 0
|
| 996 |
+
self.cache_misses = 0
|
| 997 |
+
self._score_cache = {}
|
| 998 |
+
self.cache_size = cache_size
|
| 999 |
+
self.vocab = self.tokenizer.get_vocab()
|
| 1000 |
+
|
| 1001 |
+
def _build_confusion_map(self):
|
| 1002 |
+
confusion_map = {}
|
| 1003 |
+
for char1, char2 in self.CONFUSION_PAIRS:
|
| 1004 |
+
if char1 not in confusion_map:
|
| 1005 |
+
confusion_map[char1] = []
|
| 1006 |
+
if char2 not in confusion_map:
|
| 1007 |
+
confusion_map[char2] = []
|
| 1008 |
+
confusion_map[char1].append(char2)
|
| 1009 |
+
confusion_map[char2].append(char1)
|
| 1010 |
+
return confusion_map
|
| 1011 |
+
|
| 1012 |
+
def get_confusable_chars(self, char: str) -> List[str]:
|
| 1013 |
+
return self.confusion_map.get(char, [])
|
| 1014 |
+
|
| 1015 |
+
def generate_candidates(self, word: str) -> List[str]:
|
| 1016 |
+
candidates = [word]
|
| 1017 |
+
for i, char in enumerate(word):
|
| 1018 |
+
confusables = self.get_confusable_chars(char)
|
| 1019 |
+
for conf_char in confusables:
|
| 1020 |
+
candidate = word[:i] + conf_char + word[i+1:]
|
| 1021 |
+
if candidate not in candidates:
|
| 1022 |
+
candidates.append(candidate)
|
| 1023 |
+
for i in range(len(word) - 1):
|
| 1024 |
+
if word[i] == word[i+1]:
|
| 1025 |
+
candidate = word[:i] + word[i+1:]
|
| 1026 |
+
if candidate not in candidates:
|
| 1027 |
+
candidates.append(candidate)
|
| 1028 |
+
COMMON_CHARS = 'ابتثجحخدذرزسشصضطظعغفقكلمنهويأإآءئؤةى'
|
| 1029 |
+
for i in range(len(word) + 1):
|
| 1030 |
+
for char in COMMON_CHARS:
|
| 1031 |
+
candidate = word[:i] + char + word[i:]
|
| 1032 |
+
if candidate in self.vocab and candidate not in candidates:
|
| 1033 |
+
candidates.append(candidate)
|
| 1034 |
+
if len(word) < 7:
|
| 1035 |
+
for i in range(len(word)):
|
| 1036 |
+
for char in COMMON_CHARS:
|
| 1037 |
+
if char != word[i]:
|
| 1038 |
+
candidate = word[:i] + char + word[i+1:]
|
| 1039 |
+
if candidate in self.vocab and candidate not in candidates:
|
| 1040 |
+
candidates.append(candidate)
|
| 1041 |
+
for i in range(len(word)):
|
| 1042 |
+
candidate = word[:i] + word[i+1:]
|
| 1043 |
+
if len(candidate) > 1:
|
| 1044 |
+
if candidate in self.vocab and candidate not in candidates:
|
| 1045 |
+
candidates.append(candidate)
|
| 1046 |
+
return candidates
|
| 1047 |
+
|
| 1048 |
+
def score_with_mlm(self, text: str, position: int, word: str) -> float:
|
| 1049 |
+
cache_key = f"{text}|{position}|{word}"
|
| 1050 |
+
if cache_key in self._score_cache:
|
| 1051 |
+
self.cache_hits += 1
|
| 1052 |
+
return self._score_cache[cache_key]
|
| 1053 |
+
self.cache_misses += 1
|
| 1054 |
+
words = text.split()
|
| 1055 |
+
if position >= len(words):
|
| 1056 |
+
return 0.0
|
| 1057 |
+
masked_words = words.copy()
|
| 1058 |
+
masked_words[position] = '[MASK]'
|
| 1059 |
+
masked_text = ' '.join(masked_words)
|
| 1060 |
+
inputs = self.tokenizer(masked_text, return_tensors='pt', padding=True, truncation=True)
|
| 1061 |
+
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
| 1062 |
+
with torch.no_grad():
|
| 1063 |
+
outputs = self.model(**inputs)
|
| 1064 |
+
predictions = outputs.logits
|
| 1065 |
+
mask_token_index = (inputs['input_ids'] == self.tokenizer.mask_token_id).nonzero(as_tuple=True)[1]
|
| 1066 |
+
if len(mask_token_index) == 0:
|
| 1067 |
+
return 0.0
|
| 1068 |
+
mask_token_logits = predictions[0, mask_token_index[0], :]
|
| 1069 |
+
probs = torch.softmax(mask_token_logits, dim=0)
|
| 1070 |
+
word_tokens = self.tokenizer.encode(word, add_special_tokens=False)
|
| 1071 |
+
if not word_tokens:
|
| 1072 |
+
return 0.0
|
| 1073 |
+
word_token_id = word_tokens[0]
|
| 1074 |
+
score = probs[word_token_id].item()
|
| 1075 |
+
if len(self._score_cache) >= self.cache_size:
|
| 1076 |
+
self._score_cache.pop(next(iter(self._score_cache)))
|
| 1077 |
+
self._score_cache[cache_key] = score
|
| 1078 |
+
return score
|
| 1079 |
+
|
| 1080 |
+
def score_candidates_batch(self, text: str, position: int, candidates: List[str]) -> dict:
|
| 1081 |
+
scores = {}
|
| 1082 |
+
for candidate in candidates:
|
| 1083 |
+
scores[candidate] = self.score_with_mlm(text, position, candidate)
|
| 1084 |
+
return scores
|
| 1085 |
+
|
| 1086 |
+
def predict_masked_token(self, text: str, position: int, top_k: int = 5) -> List[Tuple[str, float]]:
|
| 1087 |
+
words = text.split()
|
| 1088 |
+
if position >= len(words):
|
| 1089 |
+
return []
|
| 1090 |
+
masked_words = words.copy()
|
| 1091 |
+
masked_words[position] = '[MASK]'
|
| 1092 |
+
masked_text = ' '.join(masked_words)
|
| 1093 |
+
inputs = self.tokenizer(masked_text, return_tensors='pt', padding=True, truncation=True).to(self.device)
|
| 1094 |
+
with torch.no_grad():
|
| 1095 |
+
outputs = self.model(**inputs)
|
| 1096 |
+
predictions = outputs.logits
|
| 1097 |
+
mask_token_index = (inputs['input_ids'] == self.tokenizer.mask_token_id).nonzero(as_tuple=True)[1]
|
| 1098 |
+
if len(mask_token_index) == 0:
|
| 1099 |
+
return []
|
| 1100 |
+
mask_token_logits = predictions[0, mask_token_index[0], :]
|
| 1101 |
+
probs = torch.softmax(mask_token_logits, dim=0)
|
| 1102 |
+
top_k_weights, top_k_indices = torch.topk(probs, top_k, sorted=True)
|
| 1103 |
+
results = []
|
| 1104 |
+
for i in range(top_k):
|
| 1105 |
+
token_id = top_k_indices[i].item()
|
| 1106 |
+
score = top_k_weights[i].item()
|
| 1107 |
+
token = self.tokenizer.decode([token_id]).strip()
|
| 1108 |
+
if not token.startswith("##") and token not in self.tokenizer.all_special_tokens:
|
| 1109 |
+
results.append((token, score))
|
| 1110 |
+
return results
|
| 1111 |
+
|
| 1112 |
+
def refine_sentence_with_mask(self, text: str, threshold: float = 0.001, vocab_manager=None, raw_model_output=None) -> str:
|
| 1113 |
+
words = text.split()
|
| 1114 |
+
refined_words = words.copy()
|
| 1115 |
+
raw_words = raw_model_output.split() if raw_model_output else []
|
| 1116 |
+
for i, word in enumerate(words):
|
| 1117 |
+
if vocab_manager and vocab_manager.is_iv(word):
|
| 1118 |
+
continue
|
| 1119 |
+
if i < len(raw_words) and word == raw_words[i]:
|
| 1120 |
+
continue
|
| 1121 |
+
if len(word) <= 2:
|
| 1122 |
+
continue
|
| 1123 |
+
current_score = self.score_with_mlm(text, i, word)
|
| 1124 |
+
if current_score > threshold:
|
| 1125 |
+
continue
|
| 1126 |
+
predictions = self.predict_masked_token(text, i, top_k=10)
|
| 1127 |
+
for pred_word, pred_score in predictions:
|
| 1128 |
+
if pred_word == word:
|
| 1129 |
+
continue
|
| 1130 |
+
if abs(len(pred_word) - len(word)) > 1:
|
| 1131 |
+
continue
|
| 1132 |
+
dist = Levenshtein.distance(word, pred_word)
|
| 1133 |
+
max_len = max(len(word), len(pred_word))
|
| 1134 |
+
similarity = 1.0 - (dist / max_len)
|
| 1135 |
+
if similarity < 0.90:
|
| 1136 |
+
continue
|
| 1137 |
+
if vocab_manager and vocab_manager.is_oov(pred_word):
|
| 1138 |
+
continue
|
| 1139 |
+
if pred_score < 0.12:
|
| 1140 |
+
continue
|
| 1141 |
+
is_original_common = current_score > 0.001
|
| 1142 |
+
if is_original_common:
|
| 1143 |
+
if pred_score > current_score * 1000:
|
| 1144 |
+
refined_words[i] = pred_word
|
| 1145 |
+
break
|
| 1146 |
+
else:
|
| 1147 |
+
if pred_score > current_score * 50 and pred_score > 0.2:
|
| 1148 |
+
refined_words[i] = pred_word
|
| 1149 |
+
break
|
| 1150 |
+
return ' '.join(refined_words)
|
| 1151 |
+
|
| 1152 |
+
def calculate_sentence_score(self, text: str) -> float:
|
| 1153 |
+
words = text.split()
|
| 1154 |
+
if not words:
|
| 1155 |
+
return 0.0
|
| 1156 |
+
total_score = 0.0
|
| 1157 |
+
scored_words = 0
|
| 1158 |
+
for i, word in enumerate(words):
|
| 1159 |
+
score = self.score_with_mlm(text, i, word)
|
| 1160 |
+
total_score += score
|
| 1161 |
+
scored_words += 1
|
| 1162 |
+
if scored_words == 0:
|
| 1163 |
+
return 0.0
|
| 1164 |
+
return total_score / scored_words
|
| 1165 |
+
|
| 1166 |
+
|
| 1167 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 1168 |
+
# MAIN SPELL CHECKER CLASS
|
| 1169 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 1170 |
+
|
| 1171 |
+
class ArabicSpellChecker:
|
| 1172 |
+
"""Main Arabic Spell Checker class"""
|
| 1173 |
+
|
| 1174 |
+
def __init__(self, model, tokenizer, device, use_contextual: bool = True):
|
| 1175 |
+
self.model = model
|
| 1176 |
+
self.tokenizer = tokenizer
|
| 1177 |
+
self.device = device
|
| 1178 |
+
|
| 1179 |
+
self.postprocessor = AraSpellPostProcessor()
|
| 1180 |
+
self.classifier = ErrorClassifier()
|
| 1181 |
+
self.rules = RulesBasedCorrector()
|
| 1182 |
+
self.validator = OutputValidator()
|
| 1183 |
+
self.vocab_manager = VocabularyManager(tokenizer)
|
| 1184 |
+
self.edit_corrector = EditDistanceCorrector(tokenizer)
|
| 1185 |
+
self.split_merge = SplitMergeSpecialist(self.vocab_manager)
|
| 1186 |
+
self.word_aligner = WordAligner(self.vocab_manager)
|
| 1187 |
+
|
| 1188 |
+
self.use_contextual = use_contextual
|
| 1189 |
+
if use_contextual:
|
| 1190 |
+
try:
|
| 1191 |
+
logger.info("=" * 60)
|
| 1192 |
+
logger.info("[MLM/CONTEXTUAL] Loading AraBERT MLM model...")
|
| 1193 |
+
self.contextual = ContextualCorrector()
|
| 1194 |
+
logger.info("[MLM/CONTEXTUAL] ✅ LOADED SUCCESSFULLY")
|
| 1195 |
+
logger.info(f"[MLM/CONTEXTUAL] Device: {self.contextual.device}")
|
| 1196 |
+
logger.info(f"[MLM/CONTEXTUAL] Vocab size: {len(self.contextual.vocab)}")
|
| 1197 |
+
logger.info("=" * 60)
|
| 1198 |
+
except Exception as e:
|
| 1199 |
+
logger.warning("=" * 60)
|
| 1200 |
+
logger.warning(f"[MLM/CONTEXTUAL] ❌ FAILED TO LOAD: {e}")
|
| 1201 |
+
logger.warning("[MLM/CONTEXTUAL] Spelling will work without contextual validation")
|
| 1202 |
+
logger.warning("=" * 60)
|
| 1203 |
+
self.contextual = None
|
| 1204 |
+
self.use_contextual = False
|
| 1205 |
+
else:
|
| 1206 |
+
self.contextual = None
|
| 1207 |
+
logger.info("[MLM/CONTEXTUAL] Disabled by configuration (use_contextual=False)")
|
| 1208 |
+
|
| 1209 |
+
def _fix_repeated_end_chars(self, text: str) -> str:
|
| 1210 |
+
text = re.sub(r'([ا-ي])\1+\b', r'\1', text)
|
| 1211 |
+
return text
|
| 1212 |
+
|
| 1213 |
+
def _fix_merged_with_errors(self, text: str) -> str:
|
| 1214 |
+
text = re.sub(r'ال([ا-ي])\1+([ا-ي]{2,})', r'ال\2', text)
|
| 1215 |
+
text = re.sub(r'\b([ا-ي]{3,})([ا-ي])\2+\b', r'\1\2', text)
|
| 1216 |
+
return text
|
| 1217 |
+
|
| 1218 |
+
def _split_merged_words_linguistic(self, text: str) -> str:
|
| 1219 |
+
text = re.sub(
|
| 1220 |
+
r'\b(في|من|إلى|الى|حتى|منذ|خلال|بعد|قبل)(ال)?([ا-ي]{3,})',
|
| 1221 |
+
r'\1 \2\3', text
|
| 1222 |
+
)
|
| 1223 |
+
text = re.sub(r'\b(كل)([ا-ي]{3,})', r'\1 \2', text)
|
| 1224 |
+
text = re.sub(r'([ا-ي]{3,})(ال)([ا-ي]{3,})', r'\1 \2\3', text)
|
| 1225 |
+
text = re.sub(r'\b([بلك])(ال)?([ا-ي]{3,})', r'\1 \2\3', text)
|
| 1226 |
+
text = re.sub(r'([ا-ي]{4,})(عليكم|عليك|عليه|عليها)', r'\1 \2', text)
|
| 1227 |
+
text = re.sub(r'([ا-ي]{3,})(على|عن)([ا-ي]{3,})', r'\1 \2 \3', text)
|
| 1228 |
+
return text
|
| 1229 |
+
|
| 1230 |
+
def _split_long_words_heuristic(self, text: str, max_length: int = 15) -> str:
|
| 1231 |
+
words = text.split()
|
| 1232 |
+
result = []
|
| 1233 |
+
for word in words:
|
| 1234 |
+
if len(word) <= max_length:
|
| 1235 |
+
result.append(word)
|
| 1236 |
+
continue
|
| 1237 |
+
if 'ال' in word[2:]:
|
| 1238 |
+
parts = word.split('ال', 1)
|
| 1239 |
+
if len(parts[0]) >= 2 and len(parts[1]) >= 3:
|
| 1240 |
+
result.extend([parts[0], 'ال' + parts[1]])
|
| 1241 |
+
continue
|
| 1242 |
+
if len(word) >= 8:
|
| 1243 |
+
split_found = False
|
| 1244 |
+
for split_pos in [2, 3]:
|
| 1245 |
+
prefix = word[:split_pos]
|
| 1246 |
+
suffix = word[split_pos:]
|
| 1247 |
+
if prefix in ['في', 'من', 'على', 'عن', 'مع', 'كل', 'ب', 'ل', 'ك']:
|
| 1248 |
+
result.extend([prefix, suffix])
|
| 1249 |
+
split_found = True
|
| 1250 |
+
break
|
| 1251 |
+
if not split_found:
|
| 1252 |
+
result.append(word)
|
| 1253 |
+
else:
|
| 1254 |
+
result.append(word)
|
| 1255 |
+
return ' '.join(result)
|
| 1256 |
+
|
| 1257 |
+
def _normalize_tanween_patterns(self, text: str) -> str:
|
| 1258 |
+
text = re.sub(r'([ا-ي]{2,})أ\b', r'\1اً', text)
|
| 1259 |
+
text = re.sub(r'\s+أ\s+', ' ', text)
|
| 1260 |
+
text = re.sub(r'\b([بلك])\s+([ا-ي])', r'\1\2', text)
|
| 1261 |
+
return text
|
| 1262 |
+
|
| 1263 |
+
def preprocess(self, text: str) -> str:
|
| 1264 |
+
"""Preprocessing pipeline"""
|
| 1265 |
+
text = self.postprocessor.remove_harakat(text)
|
| 1266 |
+
text = self.postprocessor.remove_tatweel(text)
|
| 1267 |
+
text = self.postprocessor.normalize_special_chars(text)
|
| 1268 |
+
text = self._fix_repeated_end_chars(text)
|
| 1269 |
+
text = self._fix_merged_with_errors(text)
|
| 1270 |
+
text = self._split_merged_words_linguistic(text)
|
| 1271 |
+
text = self._split_long_words_heuristic(text)
|
| 1272 |
+
text = self._normalize_tanween_patterns(text)
|
| 1273 |
+
text = self.postprocessor.merge_separated_al(text)
|
| 1274 |
+
text = self.postprocessor.unified_collapse_repeated(text)
|
| 1275 |
+
text = self.rules.fix_char_substitution(text)
|
| 1276 |
+
text = self.rules.fix_char_repetition(text)
|
| 1277 |
+
text = self.postprocessor.normalize_spaces(text)
|
| 1278 |
+
return text
|
| 1279 |
+
|
| 1280 |
+
def postprocess(self, text: str, original: str = "") -> str:
|
| 1281 |
+
"""Postprocessing pipeline"""
|
| 1282 |
+
return self.postprocessor.full_postprocess(text, original, vocab_manager=self.vocab_manager)
|
| 1283 |
+
|
| 1284 |
+
def model_inference(self, text: str, num_return_sequences: int = 5) -> List[str]:
|
| 1285 |
+
"""Run seq2seq model inference and return top candidates."""
|
| 1286 |
+
inputs = self.tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=128)
|
| 1287 |
+
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
| 1288 |
+
with torch.no_grad():
|
| 1289 |
+
outputs = self.model.generate(
|
| 1290 |
+
**inputs,
|
| 1291 |
+
num_beams=5,
|
| 1292 |
+
num_return_sequences=num_return_sequences,
|
| 1293 |
+
early_stopping=True,
|
| 1294 |
+
return_dict_in_generate=True,
|
| 1295 |
+
output_scores=True
|
| 1296 |
+
)
|
| 1297 |
+
candidates = self.tokenizer.batch_decode(outputs.sequences, skip_special_tokens=True)
|
| 1298 |
+
self._last_beam_scores = {}
|
| 1299 |
+
if hasattr(outputs, 'sequences_scores') and outputs.sequences_scores is not None:
|
| 1300 |
+
scores = outputs.sequences_scores.tolist()
|
| 1301 |
+
for cand, score in zip(candidates, scores):
|
| 1302 |
+
self._last_beam_scores[cand] = score
|
| 1303 |
+
return candidates
|
| 1304 |
+
|
| 1305 |
+
def correct(self, text: str) -> str:
|
| 1306 |
+
"""
|
| 1307 |
+
Main correction pipeline (RERANKING APPROACH)
|
| 1308 |
+
|
| 1309 |
+
Steps:
|
| 1310 |
+
1. Preprocess
|
| 1311 |
+
2. Generate Candidates (Model Beams + Baseline)
|
| 1312 |
+
3. Rerank Candidates (Validator + Fluency)
|
| 1313 |
+
4. Select Best
|
| 1314 |
+
5. Postprocess
|
| 1315 |
+
"""
|
| 1316 |
+
if not text or not text.strip():
|
| 1317 |
+
return text
|
| 1318 |
+
|
| 1319 |
+
original = text
|
| 1320 |
+
|
| 1321 |
+
# 1. Preprocess
|
| 1322 |
+
preprocessed_text = self.preprocess(text)
|
| 1323 |
+
|
| 1324 |
+
# 2. Classify error type
|
| 1325 |
+
error_type = self.classifier.classify(preprocessed_text)
|
| 1326 |
+
|
| 1327 |
+
# 3. Generate Candidates
|
| 1328 |
+
candidates = []
|
| 1329 |
+
candidates.append(preprocessed_text)
|
| 1330 |
+
|
| 1331 |
+
rules_candidate = self.rules.advanced_heuristic_repair(text)
|
| 1332 |
+
candidates.append(rules_candidate)
|
| 1333 |
+
|
| 1334 |
+
edit_candidate = self.edit_corrector.generate_candidate(text)
|
| 1335 |
+
if edit_candidate != text and edit_candidate != rules_candidate:
|
| 1336 |
+
candidates.append(edit_candidate)
|
| 1337 |
+
|
| 1338 |
+
raw_model_output = None
|
| 1339 |
+
try:
|
| 1340 |
+
model_candidates = self.model_inference(preprocessed_text, num_return_sequences=5)
|
| 1341 |
+
raw_model_output = model_candidates[0] if model_candidates else None
|
| 1342 |
+
candidates.extend(model_candidates)
|
| 1343 |
+
|
| 1344 |
+
if model_candidates:
|
| 1345 |
+
hybrid_candidate = self.word_aligner.align_words(preprocessed_text, model_candidates[0])
|
| 1346 |
+
if hybrid_candidate not in candidates:
|
| 1347 |
+
candidates.append(hybrid_candidate)
|
| 1348 |
+
for beam in model_candidates[1:3]:
|
| 1349 |
+
hybrid_beam = self.word_aligner.align_words(preprocessed_text, beam)
|
| 1350 |
+
if hybrid_beam not in candidates:
|
| 1351 |
+
candidates.append(hybrid_beam)
|
| 1352 |
+
|
| 1353 |
+
if model_candidates and len(model_candidates) >= 3:
|
| 1354 |
+
try:
|
| 1355 |
+
beam_word_lists = [c.split() for c in model_candidates]
|
| 1356 |
+
max_words = max(len(wl) for wl in beam_word_lists)
|
| 1357 |
+
voted_words = []
|
| 1358 |
+
for pos in range(max_words):
|
| 1359 |
+
words_at_pos = []
|
| 1360 |
+
for wl in beam_word_lists:
|
| 1361 |
+
if pos < len(wl):
|
| 1362 |
+
words_at_pos.append(wl[pos])
|
| 1363 |
+
if words_at_pos:
|
| 1364 |
+
most_common = Counter(words_at_pos).most_common(1)[0][0]
|
| 1365 |
+
voted_words.append(most_common)
|
| 1366 |
+
voted_candidate = ' '.join(voted_words)
|
| 1367 |
+
if voted_candidate not in candidates:
|
| 1368 |
+
candidates.append(voted_candidate)
|
| 1369 |
+
except Exception:
|
| 1370 |
+
pass
|
| 1371 |
+
except Exception as e:
|
| 1372 |
+
logger.warning(f"Model inference failed: {e}")
|
| 1373 |
+
|
| 1374 |
+
# Remove duplicates
|
| 1375 |
+
unique_candidates = []
|
| 1376 |
+
seen = set()
|
| 1377 |
+
for c in candidates:
|
| 1378 |
+
if c not in seen:
|
| 1379 |
+
unique_candidates.append(c)
|
| 1380 |
+
seen.add(c)
|
| 1381 |
+
candidates = unique_candidates
|
| 1382 |
+
|
| 1383 |
+
# 4. Rerank Candidates
|
| 1384 |
+
best_candidate = preprocessed_text
|
| 1385 |
+
best_score = -1.0
|
| 1386 |
+
candidate_scores = []
|
| 1387 |
+
|
| 1388 |
+
for cand in candidates:
|
| 1389 |
+
is_valid, reason = self.validator.validate(original, cand, error_type.value)
|
| 1390 |
+
if len(cand) < len(original) * 0.5:
|
| 1391 |
+
is_valid = False
|
| 1392 |
+
reason = "too_short"
|
| 1393 |
+
|
| 1394 |
+
input_oov_count = self.vocab_manager.count_oov_words(original)
|
| 1395 |
+
cand_oov_count = self.vocab_manager.count_oov_words(cand)
|
| 1396 |
+
vocab_boost = 1.0
|
| 1397 |
+
|
| 1398 |
+
if input_oov_count > 0 and cand_oov_count < input_oov_count:
|
| 1399 |
+
oov_reduction = input_oov_count - cand_oov_count
|
| 1400 |
+
vocab_boost = 1.0 + (oov_reduction * 0.3)
|
| 1401 |
+
if cand_oov_count == 0 and self.vocab_manager.all_words_iv(cand):
|
| 1402 |
+
if not is_valid and reason not in ["empty_output"]:
|
| 1403 |
+
is_valid = True
|
| 1404 |
+
reason = "vocab_aware_accept"
|
| 1405 |
+
elif cand_oov_count > input_oov_count:
|
| 1406 |
+
vocab_boost = 0.5
|
| 1407 |
+
elif input_oov_count == 0 and cand_oov_count == 0:
|
| 1408 |
+
vocab_boost = 1.0
|
| 1409 |
+
|
| 1410 |
+
validity_factor = 1.0 if is_valid else 0.001
|
| 1411 |
+
|
| 1412 |
+
fluency_score = 0.0
|
| 1413 |
+
if self.use_contextual and self.contextual:
|
| 1414 |
+
try:
|
| 1415 |
+
fluency_score = self.contextual.calculate_sentence_score(cand)
|
| 1416 |
+
except Exception as e:
|
| 1417 |
+
logger.warning(f"Scoring failed: {e}")
|
| 1418 |
+
fluency_score = 0.5
|
| 1419 |
+
else:
|
| 1420 |
+
fluency_score = 1.0
|
| 1421 |
+
|
| 1422 |
+
dist = VocabularyManager.damerau_levenshtein_distance(preprocessed_text, cand)
|
| 1423 |
+
max_len = max(len(preprocessed_text), len(cand), 1)
|
| 1424 |
+
similarity = 1.0 - (dist / max_len)
|
| 1425 |
+
if cand == preprocessed_text:
|
| 1426 |
+
similarity = 1.0
|
| 1427 |
+
|
| 1428 |
+
keyboard_bonus = 1.0
|
| 1429 |
+
input_words = preprocessed_text.split()
|
| 1430 |
+
cand_words = cand.split()
|
| 1431 |
+
if len(input_words) == len(cand_words):
|
| 1432 |
+
for iw, cw in zip(input_words, cand_words):
|
| 1433 |
+
if iw != cw and len(iw) == len(cw):
|
| 1434 |
+
for ic, cc in zip(iw, cw):
|
| 1435 |
+
if ic != cc and RulesBasedCorrector.is_keyboard_neighbor(ic, cc):
|
| 1436 |
+
keyboard_bonus *= 1.05
|
| 1437 |
+
|
| 1438 |
+
if fluency_score > 0.85 and cand_oov_count == 0:
|
| 1439 |
+
if not is_valid and reason in ["too_short", "low_character_similarity", "word_count_mismatch"]:
|
| 1440 |
+
if len(cand) >= len(original) * 0.4:
|
| 1441 |
+
is_valid = True
|
| 1442 |
+
reason = "high_confidence_override"
|
| 1443 |
+
vocab_boost *= 1.2
|
| 1444 |
+
validity_factor = 1.0
|
| 1445 |
+
|
| 1446 |
+
fluency_exp = 0.3
|
| 1447 |
+
similarity_exp = 3.0
|
| 1448 |
+
beam_boost = 1.0
|
| 1449 |
+
if raw_model_output and cand == raw_model_output:
|
| 1450 |
+
beam_boost = 1.15
|
| 1451 |
+
|
| 1452 |
+
final_score = (fluency_score ** fluency_exp) * (similarity ** similarity_exp) * validity_factor * vocab_boost * keyboard_bonus * beam_boost
|
| 1453 |
+
|
| 1454 |
+
candidate_scores.append({
|
| 1455 |
+
'text': cand, 'is_valid': is_valid, 'reason': reason,
|
| 1456 |
+
'fluency': fluency_score, 'similarity': similarity,
|
| 1457 |
+
'vocab_boost': vocab_boost, 'input_oov': input_oov_count,
|
| 1458 |
+
'cand_oov': cand_oov_count, 'final_score': final_score
|
| 1459 |
+
})
|
| 1460 |
+
|
| 1461 |
+
if final_score > best_score:
|
| 1462 |
+
best_score = final_score
|
| 1463 |
+
best_candidate = cand
|
| 1464 |
+
|
| 1465 |
+
# Output Quality Scoring
|
| 1466 |
+
if best_candidate != preprocessed_text:
|
| 1467 |
+
preprocessed_score = 0.0
|
| 1468 |
+
for cs in candidate_scores:
|
| 1469 |
+
if cs['text'] == preprocessed_text:
|
| 1470 |
+
preprocessed_score = cs['final_score']
|
| 1471 |
+
break
|
| 1472 |
+
if preprocessed_score > 0 and best_score < preprocessed_score * 1.05:
|
| 1473 |
+
best_oov = self.vocab_manager.count_oov_words(best_candidate)
|
| 1474 |
+
prep_oov = self.vocab_manager.count_oov_words(preprocessed_text)
|
| 1475 |
+
if best_oov > prep_oov:
|
| 1476 |
+
best_candidate = preprocessed_text
|
| 1477 |
+
best_score = preprocessed_score
|
| 1478 |
+
|
| 1479 |
+
# Contextual Validation Layer
|
| 1480 |
+
if best_candidate != preprocessed_text and self.use_contextual and self.contextual:
|
| 1481 |
+
try:
|
| 1482 |
+
input_fluency = self.contextual.calculate_sentence_score(preprocessed_text)
|
| 1483 |
+
best_fluency = 0.0
|
| 1484 |
+
for cs in candidate_scores:
|
| 1485 |
+
if cs['text'] == best_candidate:
|
| 1486 |
+
best_fluency = cs['fluency']
|
| 1487 |
+
break
|
| 1488 |
+
if input_fluency > 0 and best_fluency > 0:
|
| 1489 |
+
if input_fluency > best_fluency * 1.5:
|
| 1490 |
+
input_oov = self.vocab_manager.count_oov_words(preprocessed_text)
|
| 1491 |
+
best_oov = self.vocab_manager.count_oov_words(best_candidate)
|
| 1492 |
+
if input_oov <= best_oov:
|
| 1493 |
+
best_candidate = preprocessed_text
|
| 1494 |
+
except Exception:
|
| 1495 |
+
pass
|
| 1496 |
+
|
| 1497 |
+
# 5. Postprocess Winner
|
| 1498 |
+
result = self.postprocess(best_candidate, original)
|
| 1499 |
+
|
| 1500 |
+
# IV-Safe Postprocessing Check
|
| 1501 |
+
if result != best_candidate:
|
| 1502 |
+
result_words = result.split()
|
| 1503 |
+
best_words = best_candidate.split()
|
| 1504 |
+
if len(result_words) == len(best_words):
|
| 1505 |
+
fixed_words = []
|
| 1506 |
+
for idx_fw, (rw, bw) in enumerate(zip(result_words, best_words)):
|
| 1507 |
+
if rw != bw:
|
| 1508 |
+
bw_iv = self.vocab_manager.is_iv(bw)
|
| 1509 |
+
rw_iv = self.vocab_manager.is_iv(rw)
|
| 1510 |
+
if bw_iv and not rw_iv:
|
| 1511 |
+
fixed_words.append(bw)
|
| 1512 |
+
else:
|
| 1513 |
+
fixed_words.append(rw)
|
| 1514 |
+
else:
|
| 1515 |
+
fixed_words.append(rw)
|
| 1516 |
+
result = ' '.join(fixed_words)
|
| 1517 |
+
|
| 1518 |
+
# 6. Contextual fine-tuning
|
| 1519 |
+
if self.use_contextual and self.contextual:
|
| 1520 |
+
if len(result) > 3:
|
| 1521 |
+
result = self.contextual.refine_sentence_with_mask(
|
| 1522 |
+
result, vocab_manager=self.vocab_manager,
|
| 1523 |
+
raw_model_output=raw_model_output
|
| 1524 |
+
)
|
| 1525 |
+
|
| 1526 |
+
# 7. Safe Split/Merge Post-processing
|
| 1527 |
+
result = self.split_merge.merge_fragments(result)
|
| 1528 |
+
|
| 1529 |
+
# 8. Output Stability Test
|
| 1530 |
+
if result != preprocessed_text and raw_model_output:
|
| 1531 |
+
try:
|
| 1532 |
+
re_preprocessed = self.preprocess(result)
|
| 1533 |
+
stability_dist = VocabularyManager.damerau_levenshtein_distance(result, re_preprocessed)
|
| 1534 |
+
result_len = max(len(result), 1)
|
| 1535 |
+
if stability_dist > 0:
|
| 1536 |
+
stability_ratio = stability_dist / result_len
|
| 1537 |
+
if stability_ratio > 0.15:
|
| 1538 |
+
raw_re = self.preprocess(raw_model_output)
|
| 1539 |
+
raw_stability = VocabularyManager.damerau_levenshtein_distance(
|
| 1540 |
+
raw_model_output, raw_re
|
| 1541 |
+
) / max(len(raw_model_output), 1)
|
| 1542 |
+
if raw_stability < stability_ratio:
|
| 1543 |
+
raw_oov = self.vocab_manager.count_oov_words(raw_model_output)
|
| 1544 |
+
our_oov = self.vocab_manager.count_oov_words(result)
|
| 1545 |
+
if raw_oov <= our_oov:
|
| 1546 |
+
result = raw_model_output
|
| 1547 |
+
except Exception:
|
| 1548 |
+
pass
|
| 1549 |
+
|
| 1550 |
+
# 9. Bidirectional Word-Level Validation
|
| 1551 |
+
if raw_model_output and result != raw_model_output:
|
| 1552 |
+
result_words = result.split()
|
| 1553 |
+
raw_words = raw_model_output.split()
|
| 1554 |
+
if len(result_words) == len(raw_words):
|
| 1555 |
+
corrected_words = []
|
| 1556 |
+
changed = False
|
| 1557 |
+
for rw, raw_w in zip(result_words, raw_words):
|
| 1558 |
+
if rw != raw_w:
|
| 1559 |
+
rw_iv = self.vocab_manager.is_iv(rw)
|
| 1560 |
+
raw_iv = self.vocab_manager.is_iv(raw_w)
|
| 1561 |
+
if not rw_iv and raw_iv:
|
| 1562 |
+
corrected_words.append(raw_w)
|
| 1563 |
+
changed = True
|
| 1564 |
+
elif rw_iv and raw_iv:
|
| 1565 |
+
input_words_list = preprocessed_text.split()
|
| 1566 |
+
idx = len(corrected_words)
|
| 1567 |
+
if idx < len(input_words_list):
|
| 1568 |
+
input_w = input_words_list[idx]
|
| 1569 |
+
rw_dist = Levenshtein.distance(input_w, rw)
|
| 1570 |
+
raw_dist = Levenshtein.distance(input_w, raw_w)
|
| 1571 |
+
if raw_dist < rw_dist:
|
| 1572 |
+
corrected_words.append(raw_w)
|
| 1573 |
+
changed = True
|
| 1574 |
+
else:
|
| 1575 |
+
corrected_words.append(rw)
|
| 1576 |
+
else:
|
| 1577 |
+
corrected_words.append(rw)
|
| 1578 |
+
else:
|
| 1579 |
+
corrected_words.append(rw)
|
| 1580 |
+
else:
|
| 1581 |
+
corrected_words.append(rw)
|
| 1582 |
+
if changed:
|
| 1583 |
+
new_result = ' '.join(corrected_words)
|
| 1584 |
+
new_oov = self.vocab_manager.count_oov_words(new_result)
|
| 1585 |
+
old_oov = self.vocab_manager.count_oov_words(result)
|
| 1586 |
+
if new_oov <= old_oov:
|
| 1587 |
+
result = new_result
|
| 1588 |
+
|
| 1589 |
+
# 10. SAFETY NET
|
| 1590 |
+
if raw_model_output and raw_model_output != result:
|
| 1591 |
+
raw_oov = self.vocab_manager.count_oov_words(raw_model_output)
|
| 1592 |
+
our_oov = self.vocab_manager.count_oov_words(result)
|
| 1593 |
+
if raw_oov == 0 and our_oov > 0:
|
| 1594 |
+
is_valid, reason = self.validator.validate(original, raw_model_output, "mixed")
|
| 1595 |
+
if is_valid or reason == "space_leniency_accept":
|
| 1596 |
+
result = raw_model_output
|
| 1597 |
+
elif raw_oov == 0 and our_oov == 0:
|
| 1598 |
+
raw_dist = VocabularyManager.damerau_levenshtein_distance(original, raw_model_output)
|
| 1599 |
+
our_dist = VocabularyManager.damerau_levenshtein_distance(original, result)
|
| 1600 |
+
result_vs_raw_dist = VocabularyManager.damerau_levenshtein_distance(result, raw_model_output)
|
| 1601 |
+
if raw_dist < our_dist and result_vs_raw_dist <= 3:
|
| 1602 |
+
raw_valid, _ = self.validator.validate(original, raw_model_output, "mixed")
|
| 1603 |
+
if raw_valid:
|
| 1604 |
+
result = raw_model_output
|
| 1605 |
+
elif raw_oov == 0:
|
| 1606 |
+
raw_wc = len(raw_model_output.split())
|
| 1607 |
+
our_wc = len(result.split())
|
| 1608 |
+
if raw_wc != our_wc:
|
| 1609 |
+
raw_dist = VocabularyManager.damerau_levenshtein_distance(original, raw_model_output)
|
| 1610 |
+
our_dist = VocabularyManager.damerau_levenshtein_distance(original, result)
|
| 1611 |
+
if raw_dist < our_dist:
|
| 1612 |
+
raw_valid, _ = self.validator.validate(original, raw_model_output, "mixed")
|
| 1613 |
+
if raw_valid:
|
| 1614 |
+
result = raw_model_output
|
| 1615 |
+
# ── FINAL PASS: Hamza whitelist + Ta Marbuta fixes (unrevertable) ──
|
| 1616 |
+
# These are applied AFTER all validation/safety steps so they can't
|
| 1617 |
+
# be undone by Steps 8-10 which compare against raw_model_output.
|
| 1618 |
+
# The root issue: Steps 8-10 use edit distance to INPUT (which has errors)
|
| 1619 |
+
# so they revert corrections back to the erroneous form.
|
| 1620 |
+
result = AraSpellPostProcessor.fix_common_hamza(result)
|
| 1621 |
+
result = AraSpellPostProcessor.fix_ha_ta_marbuta(result, vocab_manager=self.vocab_manager)
|
| 1622 |
+
|
| 1623 |
+
# 11. DESTRUCTIVE TOKENIZATION GUARD
|
| 1624 |
+
# Arabic orthography does not use standalone 1-letter words except prepositions.
|
| 1625 |
+
# If the model creates a standalone 1-letter word that was not in the original,
|
| 1626 |
+
# check if it's a legitimate prefix separation (e.g. بالشاروع→ب الشارع).
|
| 1627 |
+
orig_standalone = set(w for w in original.split() if len(w) == 1)
|
| 1628 |
+
orig_words = original.split()
|
| 1629 |
+
res_words_list = result.split()
|
| 1630 |
+
for idx, w in enumerate(res_words_list):
|
| 1631 |
+
if len(w) == 1 and w not in orig_standalone:
|
| 1632 |
+
if w in 'واتيبلفك':
|
| 1633 |
+
# Check if this is a legitimate prefix separation:
|
| 1634 |
+
# The original word should have started with this letter as a prefix
|
| 1635 |
+
is_prefix_separation = False
|
| 1636 |
+
if w in 'وفبلك' and idx + 1 < len(res_words_list):
|
| 1637 |
+
next_word = res_words_list[idx + 1]
|
| 1638 |
+
combined = w + next_word
|
| 1639 |
+
# If any original word started with the prefix letter and
|
| 1640 |
+
# the remainder matches the next word, it's legitimate
|
| 1641 |
+
for ow in orig_words:
|
| 1642 |
+
if ow.startswith(w) and len(ow) > 2:
|
| 1643 |
+
is_prefix_separation = True
|
| 1644 |
+
break
|
| 1645 |
+
|
| 1646 |
+
if not is_prefix_separation:
|
| 1647 |
+
logger.info(f"[SPELLING] Blocked destructive tokenization (hallucinated standalone '{w}'): '{original}' -> '{result}'")
|
| 1648 |
+
result = original
|
| 1649 |
+
break
|
| 1650 |
+
|
| 1651 |
+
# 12. MORPHOLOGICAL MUTATION GUARD (Verb -> Noun)
|
| 1652 |
+
# Prevents spelling from changing a plural verb (e.g. صممو) to a noun (e.g. مصممو) by prepending م
|
| 1653 |
+
if len(orig_words) == len(res_words_list):
|
| 1654 |
+
for idx in range(len(orig_words)):
|
| 1655 |
+
ow = orig_words[idx]
|
| 1656 |
+
rw = res_words_list[idx]
|
| 1657 |
+
# If the word didn't start with م but the correction does, and it looks like a plural verb
|
| 1658 |
+
if not ow.startswith('م') and rw.startswith('م') and rw[1:] == ow and ow.endswith('و'):
|
| 1659 |
+
logger.info(f"[SPELLING] Blocked morphological mutation (verb→noun '{ow}'→'{rw}')")
|
| 1660 |
+
res_words_list[idx] = ow
|
| 1661 |
+
result = ' '.join(res_words_list)
|
| 1662 |
+
|
| 1663 |
+
return result
|
old_gram2.py
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ArabicGrammarGuard — Rule-based Arabic grammar post-processing
|
| 2 |
+
# Extracted from Grammer_Rules.py — uses camel-tools for morphological analysis.
|
| 3 |
+
# All classes are imported by grammar_service.py.
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
import logging
|
| 7 |
+
from camel_tools.tokenizers.word import simple_word_tokenize
|
| 8 |
+
from camel_tools.disambig.mle import MLEDisambiguator
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
KNOWN_FEMININE_NOUNS = {
|
| 13 |
+
'السيارة', 'سيارة', 'المدرسة', 'مدرسة', 'المدينة', 'مدينة',
|
| 14 |
+
'البنت', 'الشمس', 'الأرض', 'الطالبة', 'طالبة',
|
| 15 |
+
'الجامعة', 'جامعة', 'الشركة', 'شركة', 'الحكومة', 'حكومة',
|
| 16 |
+
'الغرفة', 'غرفة', 'الحديقة', 'حديقة', 'المكتبة', 'مكتبة',
|
| 17 |
+
'الدولة', 'دولة', 'الرحلة', 'رحلة', 'اللغة', 'لغة',
|
| 18 |
+
'القصة', 'قصة', 'الفكرة', 'فكرة', 'النتيجة', 'نتيجة',
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# Common adjectives that have masculine/feminine pairs
|
| 22 |
+
MASC_TO_FEM_ADJ = {
|
| 23 |
+
'جميل': 'جميلة', 'كبير': 'كبيرة', 'صغير': 'صغيرة',
|
| 24 |
+
'طويل': 'طويلة', 'قصير': 'قصيرة', 'جديد': 'جديدة',
|
| 25 |
+
'قديم': 'قديمة', 'بعيد': 'بعيدة', 'قريب': 'قريبة',
|
| 26 |
+
'سريع': 'سريعة', 'بطيء': 'بطيئة', 'واسع': 'واسعة',
|
| 27 |
+
'ضيق': 'ضيقة', 'عميق': 'عميقة', 'خفيف': 'خفيفة',
|
| 28 |
+
'ثقيل': 'ثقيلة', 'نظيف': 'نظيفة', 'مشرق': 'مشرقة',
|
| 29 |
+
'ذكي': 'ذكية', 'غني': 'غنية', 'فقير': 'فقيرة',
|
| 30 |
+
'متفوق': 'متفوقة', 'مجتهد': 'مجتهدة', 'ممتاز': 'ممتازة',
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ArabicGrammarGuard:
|
| 35 |
+
def __init__(self):
|
| 36 |
+
self.mle = MLEDisambiguator.pretrained()
|
| 37 |
+
|
| 38 |
+
self.number_words = ["واحد", "اثنان", "اثنين", "ثلاث", "أربع", "خمس", "ست", "سبع", "ثمان", "تسع", "عشر",
|
| 39 |
+
"عشرون", "عشرين", "ثلاثون", "ثلاثين", "أربعون", "أربعين", "خمسون", "خمسين",
|
| 40 |
+
"ستون", "ستين", "سبعون", "سبعين", "ثمانون", "ثمانين", "تسعون", "تسعين", "مائة", "ألف"]
|
| 41 |
+
|
| 42 |
+
self.asmaa_khamsa_roots = ['اب', 'اخ', 'حم', 'فو', 'ذو']
|
| 43 |
+
|
| 44 |
+
def preserve_numbers(self, original_text, generated_text):
|
| 45 |
+
orig_digits = re.findall(r'\d+', original_text)
|
| 46 |
+
gen_digits = re.findall(r'\d+', generated_text)
|
| 47 |
+
if orig_digits and gen_digits and orig_digits != gen_digits:
|
| 48 |
+
return original_text
|
| 49 |
+
|
| 50 |
+
orig_words = [w for w in original_text.split() if any(num in w for num in self.number_words)]
|
| 51 |
+
gen_words = [w for w in generated_text.split() if any(num in w for num in self.number_words)]
|
| 52 |
+
if len(orig_words) > 0 and len(gen_words) > 0:
|
| 53 |
+
if not any(orig[:3] in gen for orig in orig_words for gen in gen_words):
|
| 54 |
+
return original_text
|
| 55 |
+
return generated_text
|
| 56 |
+
|
| 57 |
+
def fix_number_and_gender_agreement(self, text):
|
| 58 |
+
tokens = simple_word_tokenize(text)
|
| 59 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 60 |
+
corrected_tokens = list(tokens)
|
| 61 |
+
|
| 62 |
+
for i in range(len(disambig_tokens) - 1):
|
| 63 |
+
w1_info = disambig_tokens[i].analyses[0] if disambig_tokens[i].analyses else None
|
| 64 |
+
w2_info = disambig_tokens[i+1].analyses[0] if disambig_tokens[i+1].analyses else None
|
| 65 |
+
if not w1_info or not w2_info: continue
|
| 66 |
+
|
| 67 |
+
w1_pos = w1_info.analysis.get('pos', 'unknown')
|
| 68 |
+
w2_pos = w2_info.analysis.get('pos', 'unknown')
|
| 69 |
+
w1_word = corrected_tokens[i]
|
| 70 |
+
w2_word = corrected_tokens[i+1]
|
| 71 |
+
|
| 72 |
+
if w1_pos == 'verb' and w2_pos == 'noun':
|
| 73 |
+
if (w1_word.endswith('ون') or w1_word.endswith('وا')) and (w2_word.endswith('ون') or w2_word.endswith('ين')):
|
| 74 |
+
if w1_word.endswith('ون'): corrected_tokens[i] = w1_word[:-2]
|
| 75 |
+
elif w1_word.endswith('وا'): corrected_tokens[i] = w1_word[:-2]
|
| 76 |
+
|
| 77 |
+
return " ".join(corrected_tokens)
|
| 78 |
+
|
| 79 |
+
def smart_asmaa_khamsa_fix(self, text):
|
| 80 |
+
tokens = simple_word_tokenize(text)
|
| 81 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 82 |
+
corrected_tokens = []
|
| 83 |
+
verb_seen = False
|
| 84 |
+
|
| 85 |
+
for i, token_info in enumerate(disambig_tokens):
|
| 86 |
+
word = tokens[i]
|
| 87 |
+
|
| 88 |
+
pos_tag = token_info.analyses[0].analysis.get('pos', 'unknown') if token_info.analyses else 'unknown'
|
| 89 |
+
|
| 90 |
+
if pos_tag == 'verb':
|
| 91 |
+
verb_seen = True
|
| 92 |
+
corrected_tokens.append(word)
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
is_asmaa = any(word.startswith(root) or word.startswith('أ' + root[1:]) for root in self.asmaa_khamsa_roots if len(root)>1)
|
| 96 |
+
|
| 97 |
+
if is_asmaa and len(word) >= 3:
|
| 98 |
+
if verb_seen:
|
| 99 |
+
word = word.replace('ا', 'و').replace('ي', 'و')
|
| 100 |
+
verb_seen = False
|
| 101 |
+
|
| 102 |
+
corrected_tokens.append(word)
|
| 103 |
+
|
| 104 |
+
return " ".join(corrected_tokens)
|
| 105 |
+
|
| 106 |
+
def _apply_jazm_to_verb(self, word, token_info):
|
| 107 |
+
# 1. Handle Af'al Khamsa using camel_tools analysis
|
| 108 |
+
if token_info and token_info.analyses:
|
| 109 |
+
analysis = token_info.analyses[0].analysis
|
| 110 |
+
num = analysis.get('num', 's')
|
| 111 |
+
per = analysis.get('per', '3')
|
| 112 |
+
gen = analysis.get('gen', 'm')
|
| 113 |
+
|
| 114 |
+
if num == 'p' and gen == 'm':
|
| 115 |
+
if word.endswith('ون'):
|
| 116 |
+
return word[:-2] + 'وا'
|
| 117 |
+
elif num == 'd':
|
| 118 |
+
if word.endswith('ان'):
|
| 119 |
+
return word[:-2] + 'ا'
|
| 120 |
+
elif num == 's' and per == '2' and gen == 'f':
|
| 121 |
+
if word.endswith('ين'):
|
| 122 |
+
return word[:-2] + 'ي'
|
| 123 |
+
|
| 124 |
+
# 2. Handle defective verbs in jazm context
|
| 125 |
+
match = re.search(r'^(.*?)([يوىاَُِْ]?)$', word)
|
| 126 |
+
if match:
|
| 127 |
+
stem = match.group(1)
|
| 128 |
+
ending = match.group(2)
|
| 129 |
+
|
| 130 |
+
fatha_bases = ['سع', 'خش', 'رض', 'نس', 'بق', 'ر', 'نه', 'حظ', 'رع', 'أب', 'تمن', 'لق', 'هو', 'سل']
|
| 131 |
+
damma_bases = ['دع', 'رج', 'شك', 'نم', 'غز', 'عف', 'سم', 'دن', 'بد', 'خل', 'عل']
|
| 132 |
+
kasra_bases = ['مش', 'جر', 'قض', 'بك', 'هد', 'رم', 'أت', 'بن', 'ق', 'وف', 'شف', 'غن', 'عط', 'تق', 'شتر', 'عتن', 'ستدع', 'نته', 'رو']
|
| 133 |
+
|
| 134 |
+
fatha_stems = {p + b for p in ['ي', 'ت', 'أ', 'ن'] for b in fatha_bases}
|
| 135 |
+
damma_stems = {p + b for p in ['ي', 'ت', 'أ', 'ن'] for b in damma_bases}
|
| 136 |
+
kasra_stems = {p + b for p in ['ي', 'ت', 'أ', 'ن'] for b in kasra_bases}
|
| 137 |
+
|
| 138 |
+
if stem in fatha_stems:
|
| 139 |
+
return stem + 'َ'
|
| 140 |
+
elif stem in damma_stems:
|
| 141 |
+
return stem + 'ُ'
|
| 142 |
+
elif stem in kasra_stems:
|
| 143 |
+
return stem + 'ِ'
|
| 144 |
+
elif ending == 'و' and len(stem) >= 2:
|
| 145 |
+
return stem + 'ُ'
|
| 146 |
+
elif ending == 'ي' and len(stem) >= 2:
|
| 147 |
+
return stem + 'ِ'
|
| 148 |
+
elif (ending == 'ى' or ending == 'ا') and len(stem) >= 2:
|
| 149 |
+
if not word.endswith('وا'):
|
| 150 |
+
return stem + 'َ'
|
| 151 |
+
|
| 152 |
+
return word
|
| 153 |
+
|
| 154 |
+
def _apply_nasb_to_verb(self, word, token_info):
|
| 155 |
+
# 1. Handle Af'al Khamsa using camel_tools analysis
|
| 156 |
+
if token_info and token_info.analyses:
|
| 157 |
+
analysis = token_info.analyses[0].analysis
|
| 158 |
+
num = analysis.get('num', 's')
|
| 159 |
+
per = analysis.get('per', '3')
|
| 160 |
+
gen = analysis.get('gen', 'm')
|
| 161 |
+
|
| 162 |
+
if num == 'p' and gen == 'm':
|
| 163 |
+
if word.endswith('ون'):
|
| 164 |
+
return word[:-2] + 'وا'
|
| 165 |
+
elif num == 'd':
|
| 166 |
+
if word.endswith('ان'):
|
| 167 |
+
return word[:-2] + 'ا'
|
| 168 |
+
elif num == 's' and per == '2' and gen == 'f':
|
| 169 |
+
if word.endswith('ين'):
|
| 170 |
+
return word[:-2] + 'ي'
|
| 171 |
+
|
| 172 |
+
# 2. Handle defective verbs in nasb
|
| 173 |
+
if word.endswith('و') and len(word) > 3:
|
| 174 |
+
return word + 'َ'
|
| 175 |
+
elif word.endswith('ي') and len(word) > 3:
|
| 176 |
+
return word + 'َ'
|
| 177 |
+
|
| 178 |
+
return word
|
| 179 |
+
|
| 180 |
+
def fix_verbs_nasb_and_jazm(self, text):
|
| 181 |
+
tokens = simple_word_tokenize(text)
|
| 182 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 183 |
+
|
| 184 |
+
nasb_particles = ['أن', 'ان', 'لن', 'كي', 'لكي', 'حتى', 'حتي', 'إذن', 'اذا']
|
| 185 |
+
jazm_particles = ['لم', 'لما', 'لا']
|
| 186 |
+
|
| 187 |
+
corrected_tokens = []
|
| 188 |
+
|
| 189 |
+
for i, token_info in enumerate(disambig_tokens):
|
| 190 |
+
word = tokens[i]
|
| 191 |
+
|
| 192 |
+
pos_tag = token_info.analyses[0].analysis.get('pos', 'unknown') if token_info.analyses else 'unknown'
|
| 193 |
+
|
| 194 |
+
is_nasb_context = False
|
| 195 |
+
is_jazm_context = False
|
| 196 |
+
|
| 197 |
+
if i > 0:
|
| 198 |
+
prev_word = tokens[i-1]
|
| 199 |
+
if prev_word in nasb_particles or word.startswith('ل'):
|
| 200 |
+
is_nasb_context = True
|
| 201 |
+
if prev_word in jazm_particles or word.startswith('ل') or word.startswith('ول'):
|
| 202 |
+
is_jazm_context = True
|
| 203 |
+
|
| 204 |
+
is_present_tense = word.startswith('ي') or word.startswith('ت') or word.startswith('ن') or word.startswith('أ')
|
| 205 |
+
if (pos_tag == 'verb' or is_present_tense) and (is_nasb_context or is_jazm_context):
|
| 206 |
+
if is_jazm_context:
|
| 207 |
+
word = self._apply_jazm_to_verb(word, token_info)
|
| 208 |
+
elif is_nasb_context:
|
| 209 |
+
word = self._apply_nasb_to_verb(word, token_info)
|
| 210 |
+
|
| 211 |
+
corrected_tokens.append(word)
|
| 212 |
+
return " ".join(corrected_tokens)
|
| 213 |
+
|
| 214 |
+
def fix_gender_agreement(self, text):
|
| 215 |
+
|
| 216 |
+
text = re.sub(r'\bأحد عشر\s+([أ-ي]+ة)\b', r'إحدى عشرة \1', text)
|
| 217 |
+
text = re.sub(r'\bأحد عشرة\s+([أ-ي]+ة)\b', r'إحدى عشرة \1', text)
|
| 218 |
+
|
| 219 |
+
text = re.sub(r'\bإحدى عشرة\s+([أ-ي]+ا|رجل[اأ]|طالب[اأ]|مهندس[اأ])\b', r'أحد عشر \1', text)
|
| 220 |
+
text = re.sub(r'\bإحدى عشر\s+([أ-ي]+ا|رجل[اأ]|طالب[اأ]|مهندس[اأ])\b', r'أحد عشر \1', text)
|
| 221 |
+
|
| 222 |
+
# ── Batch 6: Noun-adjective gender agreement ──
|
| 223 |
+
# When a feminine noun is followed by a masculine adjective, add ة
|
| 224 |
+
# e.g. السيارة جميل → السيارة جميلة
|
| 225 |
+
words = text.split()
|
| 226 |
+
for i in range(len(words) - 1):
|
| 227 |
+
noun = words[i]
|
| 228 |
+
adj = words[i + 1]
|
| 229 |
+
is_fem_noun = (noun in KNOWN_FEMININE_NOUNS or
|
| 230 |
+
(noun.endswith('ة') and len(noun) >= 3) or
|
| 231 |
+
(noun.startswith('ال') and noun.endswith('ة')))
|
| 232 |
+
if is_fem_noun and adj in MASC_TO_FEM_ADJ:
|
| 233 |
+
words[i + 1] = MASC_TO_FEM_ADJ[adj]
|
| 234 |
+
text = ' '.join(words)
|
| 235 |
+
|
| 236 |
+
return text
|
| 237 |
+
|
| 238 |
+
# FIX-33: Words ending in ان that are root-form nouns, NOT duals/plurals.
|
| 239 |
+
# These must never have ان→ين replacement.
|
| 240 |
+
_PREP_BLOCKLIST = {
|
| 241 |
+
'الامتحان', 'امتحان', 'الإنسان', 'إنسان', 'انسان', 'الانسان',
|
| 242 |
+
'الميدان', 'ميدان', 'البرلمان', 'برلمان', 'السلطان', 'سلطان',
|
| 243 |
+
'العنوان', 'عنوان', 'الديوان', 'ديوان', 'البستان', 'بستان',
|
| 244 |
+
'البنيان', 'بنيان', 'الإيمان', 'إيمان', 'ايمان', 'الايمان',
|
| 245 |
+
'الأمان', 'أمان', 'امان', 'الامان', 'العدوان', 'عدوان',
|
| 246 |
+
'البيان', 'بيان', 'البرهان', 'برهان', 'الشيطان', 'شيطان',
|
| 247 |
+
'الأذان', 'أذان', 'السودان', 'لبنان', 'عمان', 'الأردن',
|
| 248 |
+
'الحيوان', 'حيوان', 'القرآن', 'قرآن', 'الدخان', 'دخان',
|
| 249 |
+
'المكان', 'مكان', 'الزمان', 'زمان', 'الجدران', 'جدران',
|
| 250 |
+
'النيران', 'نيران', 'الألوان', 'ألوان', 'البلدان', 'بلدان',
|
| 251 |
+
'الأوطان', 'أوطان', 'الأبدان', 'أبدان', 'الأركان', 'أركان',
|
| 252 |
+
'الفرسان', 'فرسان', 'الغزلان', 'غزلان', 'القضبان', 'قضبان',
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
def fix_prepositions_advanced(self, text):
|
| 256 |
+
# Allow conjunctions (و، ف) before prepositions
|
| 257 |
+
# (في المهندسون) -> (في المهندسين)
|
| 258 |
+
# FIX-33: Use callback to skip root-form nouns ending in ان
|
| 259 |
+
def _prep_replace(m):
|
| 260 |
+
prep = m.group(1)
|
| 261 |
+
stem = m.group(2)
|
| 262 |
+
suffix = m.group(3)
|
| 263 |
+
full_word = stem + suffix
|
| 264 |
+
# Skip words in blocklist (root nouns, not duals)
|
| 265 |
+
if full_word in self._PREP_BLOCKLIST:
|
| 266 |
+
return m.group(0) # return unchanged
|
| 267 |
+
# Skip ال-prefixed words ending in ان — almost always root nouns
|
| 268 |
+
if stem.startswith('ال') and suffix == 'ان':
|
| 269 |
+
return m.group(0) # return unchanged
|
| 270 |
+
return f'{prep} {stem}ين'
|
| 271 |
+
|
| 272 |
+
text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن|حتى))\s+([أ-ي]{4,})(ون|ان)\b', _prep_replace, text)
|
| 273 |
+
|
| 274 |
+
# (وبالمبرمجون) -> (وبالمبرمجين)
|
| 275 |
+
# FIX-33b: Same blocklist protection as first regex
|
| 276 |
+
def _attached_prep_replace(m):
|
| 277 |
+
prefix = m.group(1) # وب، ب، فب، ول، ل، etc.
|
| 278 |
+
stem = m.group(2)
|
| 279 |
+
suffix = m.group(3)
|
| 280 |
+
full_word = 'ال' + stem + suffix # reconstruct with ال for blocklist check
|
| 281 |
+
if full_word in self._PREP_BLOCKLIST:
|
| 282 |
+
return m.group(0)
|
| 283 |
+
# Words ending in ان with 4+ char stems are almost always root nouns
|
| 284 |
+
if suffix == 'ان':
|
| 285 |
+
return m.group(0)
|
| 286 |
+
return f'{prefix}ال{stem}ين'
|
| 287 |
+
|
| 288 |
+
text = re.sub(r'\b([وف]?[بلكف])ال([أ-ي]{4,})(ون|ان)\b', _attached_prep_replace, text)
|
| 289 |
+
|
| 290 |
+
# (ولمهندسون) -> (ولمهندسين)
|
| 291 |
+
# FIX-33b: Same protection — reconstruct full word for blocklist
|
| 292 |
+
def _lam_prep_replace(m):
|
| 293 |
+
prefix = m.group(1) # ول، ل، فل
|
| 294 |
+
stem = m.group(2)
|
| 295 |
+
suffix = m.group(3)
|
| 296 |
+
# Check blocklist with common prefixed forms
|
| 297 |
+
if (stem + suffix) in self._PREP_BLOCKLIST:
|
| 298 |
+
return m.group(0)
|
| 299 |
+
if suffix == 'ان':
|
| 300 |
+
return m.group(0)
|
| 301 |
+
return f'{prefix}{stem}ين'
|
| 302 |
+
|
| 303 |
+
text = re.sub(r'\b([وف]?ل)([أ-ي]{4,})(ون|ان)\b', _lam_prep_replace, text)
|
| 304 |
+
return text
|
| 305 |
+
|
| 306 |
+
def fix_kana_and_inna(self, text):
|
| 307 |
+
"""
|
| 308 |
+
Fix cases (Nominative/Accusative) of nouns after Inna and Kana sisters.
|
| 309 |
+
"""
|
| 310 |
+
tokens = simple_word_tokenize(text)
|
| 311 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 312 |
+
corrected_tokens = list(tokens)
|
| 313 |
+
|
| 314 |
+
INNA_SISTERS = {'إن', 'أن', 'كأ��', 'لكن', 'ليت', 'لعل', 'ان'}
|
| 315 |
+
KANA_SISTERS = {'كان', 'أصبح', 'اصبح', 'أضحى', 'اضحى', 'ظل', 'أمسى', 'امسى', 'بات', 'صار', 'ليس'}
|
| 316 |
+
|
| 317 |
+
def get_corrected_case(word, target_case, num):
|
| 318 |
+
if target_case == 'a': # Mansoub
|
| 319 |
+
if word == 'أبو': return 'أبا'
|
| 320 |
+
if word == 'أخو': return 'أخا'
|
| 321 |
+
if word == 'ذو': return 'ذا'
|
| 322 |
+
if word == 'فو': return 'فا'
|
| 323 |
+
if word == 'حمو': return 'حما'
|
| 324 |
+
if word.endswith('ون'): return word[:-2] + 'ين'
|
| 325 |
+
if word.endswith('ان'): return word[:-2] + 'ين'
|
| 326 |
+
elif target_case == 'n': # Marfoo'
|
| 327 |
+
if word in ('أبا', 'أبي'): return 'أبو'
|
| 328 |
+
if word in ('أخا', 'أخي'): return 'أخو'
|
| 329 |
+
if word in ('ذا', 'ذي'): return 'ذو'
|
| 330 |
+
if word in ('فا', 'في'): return 'فو'
|
| 331 |
+
if word in ('حما', 'حمي'): return 'حمو'
|
| 332 |
+
if word.endswith('ين'):
|
| 333 |
+
if num == 'p': return word[:-2] + 'ون'
|
| 334 |
+
elif num == 'd': return word[:-2] + 'ان'
|
| 335 |
+
return word
|
| 336 |
+
|
| 337 |
+
state = None # 'inna' or 'kana'
|
| 338 |
+
noun_count = 0
|
| 339 |
+
subject_num = 's'
|
| 340 |
+
|
| 341 |
+
for i, t in enumerate(disambig_tokens):
|
| 342 |
+
word = corrected_tokens[i]
|
| 343 |
+
|
| 344 |
+
if word in INNA_SISTERS:
|
| 345 |
+
state = 'inna'
|
| 346 |
+
noun_count = 0
|
| 347 |
+
continue
|
| 348 |
+
elif word in KANA_SISTERS:
|
| 349 |
+
state = 'kana'
|
| 350 |
+
noun_count = 0
|
| 351 |
+
continue
|
| 352 |
+
|
| 353 |
+
if state and t.analyses:
|
| 354 |
+
analysis = t.analyses[0].analysis
|
| 355 |
+
pos = analysis.get('pos')
|
| 356 |
+
|
| 357 |
+
# Check for nouns/adjectives
|
| 358 |
+
if pos in ('noun', 'adj', 'noun_prop'):
|
| 359 |
+
num = analysis.get('num', 's')
|
| 360 |
+
noun_count += 1
|
| 361 |
+
|
| 362 |
+
new_word = word
|
| 363 |
+
if state == 'inna':
|
| 364 |
+
if noun_count == 1:
|
| 365 |
+
subject_num = num
|
| 366 |
+
new_word = get_corrected_case(word, 'a', num) # اسم إن منصوب
|
| 367 |
+
elif noun_count == 2:
|
| 368 |
+
new_word = get_corrected_case(word, 'n', subject_num) # خبر إن مرفوع
|
| 369 |
+
state = None
|
| 370 |
+
elif state == 'kana':
|
| 371 |
+
if noun_count == 1:
|
| 372 |
+
subject_num = num
|
| 373 |
+
new_word = get_corrected_case(word, 'n', num) # اسم كان مرفوع
|
| 374 |
+
elif noun_count == 2:
|
| 375 |
+
new_word = get_corrected_case(word, 'a', subject_num) # خبر كان منصوب
|
| 376 |
+
state = None
|
| 377 |
+
|
| 378 |
+
if new_word != word:
|
| 379 |
+
pattern = r'(?<![أ-يa-zA-Z])' + re.escape(word) + r'(?![أ-يa-zA-Z])'
|
| 380 |
+
text = re.sub(pattern, new_word, text, count=1)
|
| 381 |
+
corrected_tokens[i] = new_word
|
| 382 |
+
|
| 383 |
+
elif pos == 'verb':
|
| 384 |
+
# Verb encountered, predicate might be a verbal sentence, reset to avoid false positives
|
| 385 |
+
state = None
|
| 386 |
+
|
| 387 |
+
# Reset state on punctuation
|
| 388 |
+
if word in {'.', '،', ':', '؟', '!', '؛'}:
|
| 389 |
+
state = None
|
| 390 |
+
|
| 391 |
+
return text
|
| 392 |
+
|
| 393 |
+
def fix_subject_verb_agreement(self, text):
|
| 394 |
+
"""
|
| 395 |
+
Fix G1: When a CONFIRMED plural noun PRECEDES a singular verb (SVO order),
|
| 396 |
+
the verb must agree in number and gender.
|
| 397 |
+
|
| 398 |
+
Arabic rule: In VSO order, verb can be singular even with plural subject.
|
| 399 |
+
But in SVO order, subject-verb agreement is required.
|
| 400 |
+
|
| 401 |
+
EXCLUSIONS:
|
| 402 |
+
- Pronouns (أنا, أنت, هو, etc.) — these are NOT plural
|
| 403 |
+
- Proper nouns — don't modify verbs after names
|
| 404 |
+
- Words tagged as singular by the disambiguator
|
| 405 |
+
"""
|
| 406 |
+
tokens = simple_word_tokenize(text)
|
| 407 |
+
if len(tokens) < 2:
|
| 408 |
+
return text
|
| 409 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 410 |
+
corrected_tokens = list(tokens)
|
| 411 |
+
|
| 412 |
+
# Words that should NEVER trigger plural verb agreement
|
| 413 |
+
EXCLUDED_WORDS = {
|
| 414 |
+
# Pronouns (all singular/dual)
|
| 415 |
+
'أنا', 'انا', 'أنت', 'انت', 'أنتِ', 'هو', 'هي',
|
| 416 |
+
'نحن', 'أنتما', 'هما',
|
| 417 |
+
# Common words that look like nouns but aren't plural
|
| 418 |
+
'كان', 'وكان', 'كانت', 'وكانت', 'ليس', 'ليست',
|
| 419 |
+
'هذا', 'هذه', 'ذلك', 'تلك', 'هناك',
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
for i in range(len(disambig_tokens) - 1):
|
| 423 |
+
noun_info = disambig_tokens[i].analyses[0] if disambig_tokens[i].analyses else None
|
| 424 |
+
verb_info = disambig_tokens[i+1].analyses[0] if disambig_tokens[i+1].analyses else None
|
| 425 |
+
if not noun_info or not verb_info:
|
| 426 |
+
continue
|
| 427 |
+
|
| 428 |
+
noun_pos = noun_info.analysis.get('pos', 'unknown')
|
| 429 |
+
verb_pos = verb_info.analysis.get('pos', 'unknown')
|
| 430 |
+
noun_word = corrected_tokens[i]
|
| 431 |
+
verb_word = corrected_tokens[i+1]
|
| 432 |
+
|
| 433 |
+
# Skip excluded words
|
| 434 |
+
if noun_word in EXCLUDED_WORDS:
|
| 435 |
+
continue
|
| 436 |
+
|
| 437 |
+
# Known verbs that are frequently mistagged as nouns by the tagger without diacritics
|
| 438 |
+
KNOWN_VERBS = {'بنى', 'طبخ', 'صمم', 'لعب', 'كتب', 'شرح', 'حضر', 'تدرب', 'وافق', 'أصدر', 'اصدر', 'بني', 'عمل'}
|
| 439 |
+
|
| 440 |
+
# Only process noun → verb patterns (SVO order)
|
| 441 |
+
if noun_pos != 'noun' or (verb_pos != 'verb' and verb_word not in KNOWN_VERBS):
|
| 442 |
+
continue
|
| 443 |
+
|
| 444 |
+
noun_num = noun_info.analysis.get('num', 's')
|
| 445 |
+
noun_gen = noun_info.analysis.get('gen', 'm')
|
| 446 |
+
verb_num = verb_info.analysis.get('num', 's')
|
| 447 |
+
|
| 448 |
+
# Skip if verb is already plural
|
| 449 |
+
# Removed singular verb check to allow fixing gender mismatch on already plural verbs (e.g. البنات يذهبون -> يذهبن)
|
| 450 |
+
|
| 451 |
+
# Only trigger on CONFIRMED plurals:
|
| 452 |
+
# 1. Known broken plural nouns (hardcoded list)
|
| 453 |
+
# 2. Sound masculine plural ending in ون/ين
|
| 454 |
+
# 3. Sound feminine plural ending in ات
|
| 455 |
+
# Do NOT rely on POS tagger alone — it misclassifies too many words
|
| 456 |
+
|
| 457 |
+
is_plural_masc = False
|
| 458 |
+
is_plural_fem = False
|
| 459 |
+
|
| 460 |
+
KNOWN_PLURALS_MASC = {
|
| 461 |
+
'الطلاب', 'طلاب', 'الرجال', 'رجال', 'الأولاد', 'أولاد',
|
| 462 |
+
'الأطباء', 'أطباء', 'الاطباء', 'اطباء',
|
| 463 |
+
'العمال', 'عمال', 'الشباب', 'الأبناء',
|
| 464 |
+
'المهندسون', 'المعلمون', 'المهندسين', 'المعلمين',
|
| 465 |
+
# FIX-08: Expanded plural lists
|
| 466 |
+
'اللاعبون', 'اللاعبين', 'لاعبون', 'لاعبين',
|
| 467 |
+
'المسلمون', 'المسلمين', 'مسلمون', 'مسلمين',
|
| 468 |
+
'العرب', 'الناس', 'الأطفال', 'أطفال', 'اطفال',
|
| 469 |
+
'الأصدقاء', 'أصدقاء', 'اصدقاء',
|
| 470 |
+
'العلماء', 'علماء', 'الأعداء', 'أعداء',
|
| 471 |
+
'الوزراء', 'وزراء', 'الأمراء', 'أمراء',
|
| 472 |
+
'الكتّاب', 'كتّاب', 'الأدباء', 'أدباء',
|
| 473 |
+
'السكان', 'سكان', 'الجنود', 'جنود',
|
| 474 |
+
'الأساتذة', 'أساتذة', 'التلاميذ', 'تلاميذ',
|
| 475 |
+
'المواطنون', 'المواطنين', 'المسؤولون', 'المسؤولين',
|
| 476 |
+
'الطلبة', 'طلبة', 'الأقارب', 'أقارب',
|
| 477 |
+
}
|
| 478 |
+
KNOWN_PLURALS_FEM = {
|
| 479 |
+
'الطالبات', 'طالبات', 'النساء', 'نساء', 'البنات', 'بنات',
|
| 480 |
+
'المعلمات', 'معلمات', 'الأمهات', 'أمهات',
|
| 481 |
+
# FIX-08: Expanded feminine plurals
|
| 482 |
+
'المهندسات', 'مهندسات', 'الطبيبات', 'طبيبات',
|
| 483 |
+
'اللاعبات', 'لاعبات', 'الممثلات', 'ممثلات',
|
| 484 |
+
'الشركات', 'شركات', 'الجامعات', 'جامعات',
|
| 485 |
+
'المدارس', 'مدارس', 'المستشفيات', 'مستشفيات',
|
| 486 |
+
'الحكومات', 'حكومات', 'المنظمات', 'منظمات',
|
| 487 |
+
'الطائرات', 'طائرات', 'السيارات', 'سيارات',
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
if noun_word in KNOWN_PLURALS_MASC:
|
| 491 |
+
is_plural_masc = True
|
| 492 |
+
elif noun_word in KNOWN_PLURALS_FEM:
|
| 493 |
+
is_plural_fem = True
|
| 494 |
+
elif noun_word.endswith('ون') or noun_word.endswith('ين'):
|
| 495 |
+
# Sound masculine plural — but only if 4+ chars (avoid short words)
|
| 496 |
+
if len(noun_word) >= 5:
|
| 497 |
+
is_plural_masc = True
|
| 498 |
+
elif noun_word.endswith('ات') and len(noun_word) >= 5:
|
| 499 |
+
is_plural_fem = True
|
| 500 |
+
# FIX-08: Broken plural heuristic — common patterns
|
| 501 |
+
elif noun_num == 'p':
|
| 502 |
+
# Trust POS tagger when it says plural AND word is long enough
|
| 503 |
+
if len(noun_word) >= 4:
|
| 504 |
+
if noun_gen == 'f':
|
| 505 |
+
is_plural_fem = True
|
| 506 |
+
else:
|
| 507 |
+
is_plural_masc = True
|
| 508 |
+
|
| 509 |
+
is_singular_fem = False
|
| 510 |
+
if not is_plural_masc and not is_plural_fem:
|
| 511 |
+
if noun_gen == 'f' or noun_word.endswith('ة') or noun_word in KNOWN_FEMININE_NOUNS:
|
| 512 |
+
is_singular_fem = True
|
| 513 |
+
else:
|
| 514 |
+
continue
|
| 515 |
+
|
| 516 |
+
# Fix the verb to agree with the plural subject
|
| 517 |
+
# Detect if verb is present tense (starts with ي/ت/ن/أ)
|
| 518 |
+
_is_present = (verb_word.startswith('ي') or verb_word.startswith('ت')
|
| 519 |
+
or verb_word.startswith('ن') or verb_word.startswith('أ'))
|
| 520 |
+
|
| 521 |
+
if _is_present:
|
| 522 |
+
# Present tense: يذهب→يذهبون (masc) / يذهبن (fem)
|
| 523 |
+
if is_plural_fem:
|
| 524 |
+
if verb_word.endswith('ون') or verb_word.endswith('ين'):
|
| 525 |
+
verb_word = verb_word[:-2]
|
| 526 |
+
if not verb_word.endswith('ن') and not verb_word.endswith('نَ'):
|
| 527 |
+
corrected_tokens[i+1] = verb_word + 'ن'
|
| 528 |
+
elif is_plural_masc:
|
| 529 |
+
if verb_word.endswith('ن') and not verb_word.endswith('ون') and not verb_word.endswith('ين'):
|
| 530 |
+
verb_word = verb_word[:-1]
|
| 531 |
+
if (not verb_word.endswith('ون') and not verb_word.endswith('وا')
|
| 532 |
+
and not verb_word.endswith('ين')):
|
| 533 |
+
if verb_word.endswith('وَ'):
|
| 534 |
+
verb_word = verb_word[:-1]
|
| 535 |
+
corrected_tokens[i+1] = verb_word + 'ون'
|
| 536 |
+
elif is_singular_fem:
|
| 537 |
+
if verb_word.startswith('ي'):
|
| 538 |
+
corrected_tokens[i+1] = 'ت' + verb_word[1:]
|
| 539 |
+
else:
|
| 540 |
+
# Past tense: ذهب→ذهبوا (masc) / ذهبن (fem)
|
| 541 |
+
if is_plural_fem:
|
| 542 |
+
if verb_word.endswith('وا') or verb_word.endswith('ون'):
|
| 543 |
+
verb_word = verb_word[:-2]
|
| 544 |
+
elif verb_word.endswith('ت') or verb_word.endswith('تْ') or verb_word.endswith('تَ') or verb_word.endswith('و'):
|
| 545 |
+
verb_word = verb_word[:-1]
|
| 546 |
+
if not verb_word.endswith('ن') and not verb_word.endswith('نَ'):
|
| 547 |
+
if verb_word.endswith('ى') or verb_word.endswith('ا'):
|
| 548 |
+
verb_word = verb_word[:-1]
|
| 549 |
+
corrected_tokens[i+1] = verb_word + 'ن'
|
| 550 |
+
elif is_plural_masc:
|
| 551 |
+
if verb_word.endswith('ت') or verb_word.endswith('تْ') or verb_word.endswith('تَ'):
|
| 552 |
+
verb_word = verb_word[:-1]
|
| 553 |
+
if verb_word.endswith('ن') and not verb_word.endswith('ون') and not verb_word.endswith('ين'):
|
| 554 |
+
verb_word = verb_word[:-1]
|
| 555 |
+
if (not verb_word.endswith('وا') and not verb_word.endswith('ون')
|
| 556 |
+
and not verb_word.endswith('ين')):
|
| 557 |
+
if verb_word.endswith('وَ'):
|
| 558 |
+
verb_word = verb_word[:-1]
|
| 559 |
+
elif verb_word.endswith('ى') or verb_word.endswith('ا'):
|
| 560 |
+
verb_word = verb_word[:-1]
|
| 561 |
+
corrected_tokens[i+1] = verb_word + 'وا'
|
| 562 |
+
elif is_singular_fem:
|
| 563 |
+
if not verb_word.endswith('ت') and not verb_word.endswith('تْ') and not verb_word.endswith('تَ'):
|
| 564 |
+
if verb_word.endswith('ى'):
|
| 565 |
+
verb_word = verb_word[:-1] + 'ا'
|
| 566 |
+
corrected_tokens[i+1] = verb_word + 'ت'
|
| 567 |
+
|
| 568 |
+
return " ".join(corrected_tokens)
|
| 569 |
+
|
| 570 |
+
def regex_rules_fallback(self, text):
|
| 571 |
+
def _add_hamza(word):
|
| 572 |
+
if word.startswith('ا') and not word.startswith('ال'):
|
| 573 |
+
return 'أ' + word[1:]
|
| 574 |
+
return word
|
| 575 |
+
|
| 576 |
+
# إن وأخواتها
|
| 577 |
+
text = re.sub(r'\b(إن|أن|كأن|لكن|لعل|ليت|ان|كان)\s+(أبوك|ابوك|أخوك|اخوك|ذو|فوك)\b',
|
| 578 |
+
lambda m: f"{m.group(1)} {_add_hamza(m.group(2)).replace('و', 'ا')}", text)
|
| 579 |
+
|
| 580 |
+
# الأفعال المتعدية (Object position)
|
| 581 |
+
text = re.sub(r'\b(رأيت|شاهدت|قابلت|زرت|سمعت|عرفت|وجدت|أحب|أكرمت|صادفت)\s+(أبوك|ابوك|أخوك|اخوك|ذو|فوك)\b',
|
| 582 |
+
lambda m: f"{m.group(1)} {_add_hamza(m.group(2)).replace('و', 'ا')}", text)
|
| 583 |
+
|
| 584 |
+
# حروف الجر المنفصلة بمسافة (في أخوك -> في أخيك)
|
| 585 |
+
text = re.sub(r'\b([وف]?(?:في|من|إلى|الي|على|علي|عن))\s+(أبوك|ابوك|أباك|اباك|أخوك|اخوك|أخاك|اخاك|ذو|ذا)\b',
|
| 586 |
+
lambda m: f"{m.group(1)} {_add_hamza(m.group(2)).replace('و', 'ي').replace('ا', 'ي')}", text)
|
| 587 |
+
|
| 588 |
+
# حروف الجر المتصلة بدون مسافة (بأخوك، لأبوك -> بأخيك، لأبيك)
|
| 589 |
+
text = re.sub(r'\b([وف]?[بل])(أبوك|ابوك|أباك|اباك|أخوك|اخوك|أخاك|اخاك|ذو|ذا)\b',
|
| 590 |
+
lambda m: f"{m.group(1)}{m.group(2).replace('و', 'ي').replace('ا', 'ي')}", text)
|
| 591 |
+
|
| 592 |
+
# NOTE: Broad preposition case (ون→��ن) and nasb (ون→وا) regex rules
|
| 593 |
+
# were REMOVED because they caused massive overcorrection on correct text.
|
| 594 |
+
# These patterns are handled by CamelTools-based rules (fix_prepositions_advanced,
|
| 595 |
+
# fix_verbs_nasb_and_jazm) which have POS-tag awareness.
|
| 596 |
+
|
| 597 |
+
# FIX-PC010: Add targeted safe regex for Nasb/Jazm particles + verb
|
| 598 |
+
# Only match clear present tense verbs starting with ي/ت/ن/أ and ending in ون
|
| 599 |
+
text = re.sub(r'\b(أن|ان|لن|كي|حتى|لم|لما)\s+([يتا][\u0600-\u06FF]{2,})ون\b',
|
| 600 |
+
r'\1 \2وا', text)
|
| 601 |
+
|
| 602 |
+
return text
|
| 603 |
+
|
| 604 |
+
def fix_conditional_sentences(self, text):
|
| 605 |
+
conditional_particles = {'إن', 'ان', 'من', 'ما', 'متى', 'متي', 'مهما', 'أينما', 'حيثما', 'أيان', 'ايان', 'كيفما', 'أنى', 'اني'}
|
| 606 |
+
tokens = simple_word_tokenize(text)
|
| 607 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 608 |
+
corrected_tokens = list(tokens)
|
| 609 |
+
|
| 610 |
+
# Lookahead for 2nd person context
|
| 611 |
+
has_2nd_person_context = any(t.endswith('كم') or t.endswith('كمو') or t.startswith('ت') for t in tokens)
|
| 612 |
+
|
| 613 |
+
in_cond = False
|
| 614 |
+
verbs_jazmed = 0
|
| 615 |
+
|
| 616 |
+
for i, token_info in enumerate(disambig_tokens):
|
| 617 |
+
word = corrected_tokens[i]
|
| 618 |
+
pos_tag = token_info.analyses[0].analysis.get('pos', 'unknown') if token_info.analyses else 'unknown'
|
| 619 |
+
|
| 620 |
+
if word in conditional_particles:
|
| 621 |
+
# To prevent overcorrection (e.g. 'إن الأطباء' treating 'إن' as conditional),
|
| 622 |
+
# ensure the immediately following word is a verb.
|
| 623 |
+
next_pos = 'unknown'
|
| 624 |
+
if i + 1 < len(disambig_tokens):
|
| 625 |
+
if disambig_tokens[i+1].analyses:
|
| 626 |
+
next_pos = disambig_tokens[i+1].analyses[0].analysis.get('pos', 'unknown')
|
| 627 |
+
|
| 628 |
+
if next_pos == 'verb':
|
| 629 |
+
in_cond = True
|
| 630 |
+
verbs_jazmed = 0
|
| 631 |
+
continue
|
| 632 |
+
|
| 633 |
+
if in_cond and pos_tag == 'verb':
|
| 634 |
+
# Apply jazm using the comprehensive camel_tools helper
|
| 635 |
+
word = self._apply_jazm_to_verb(word, token_info)
|
| 636 |
+
|
| 637 |
+
# Fix pronoun mismatch if 2nd person context exists
|
| 638 |
+
if has_2nd_person_context and word.startswith('ي') and (word.endswith('وا') or word.endswith('ا') or word.endswith('ي')):
|
| 639 |
+
word = 'ت' + word[1:]
|
| 640 |
+
|
| 641 |
+
corrected_tokens[i] = word
|
| 642 |
+
# Increment jazmed verbs counter (handles both فعل الشرط and جواب الشرط)
|
| 643 |
+
verbs_jazmed += 1
|
| 644 |
+
if verbs_jazmed >= 2:
|
| 645 |
+
in_cond = False
|
| 646 |
+
|
| 647 |
+
return " ".join(corrected_tokens)
|
| 648 |
+
|
| 649 |
+
def fix_demonstrative_agreement(self, text):
|
| 650 |
+
tokens = simple_word_tokenize(text)
|
| 651 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 652 |
+
corrected_tokens = list(tokens)
|
| 653 |
+
|
| 654 |
+
for i in range(len(disambig_tokens) - 1):
|
| 655 |
+
w1 = corrected_tokens[i]
|
| 656 |
+
w2 = corrected_tokens[i+1]
|
| 657 |
+
|
| 658 |
+
if w1 not in ['هذا', 'هذه', 'هذان', 'هاتان', 'هذين', 'هاتين', 'هؤلاء']:
|
| 659 |
+
continue
|
| 660 |
+
|
| 661 |
+
w2_info = disambig_tokens[i+1].analyses[0].analysis if disambig_tokens[i+1].analyses else {}
|
| 662 |
+
w2_num = w2_info.get('num', 's')
|
| 663 |
+
w2_gen = w2_info.get('gen', 'm')
|
| 664 |
+
|
| 665 |
+
# Use heuristics to override or reinforce POS tags for duals
|
| 666 |
+
if w2.endswith('تان') or w2.endswith('تين'):
|
| 667 |
+
if w2_num == 'd':
|
| 668 |
+
w2_gen = 'f'
|
| 669 |
+
|
| 670 |
+
if w2_num == 'd':
|
| 671 |
+
is_nom = w2.endswith('ان') or w2.endswith('تان')
|
| 672 |
+
if w2_gen == 'f':
|
| 673 |
+
corrected_tokens[i] = 'هاتان' if is_nom else 'هاتين'
|
| 674 |
+
elif w2_gen == 'm':
|
| 675 |
+
corrected_tokens[i] = 'هذان' if is_nom else 'هذين'
|
| 676 |
+
|
| 677 |
+
return " ".join(corrected_tokens)
|
| 678 |
+
|
| 679 |
+
def fix_noun_adjective_agreement_advanced(self, text):
|
| 680 |
+
tokens = simple_word_tokenize(text)
|
| 681 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 682 |
+
corrected_tokens = list(tokens)
|
| 683 |
+
|
| 684 |
+
for i in range(len(disambig_tokens) - 1):
|
| 685 |
+
w1 = corrected_tokens[i]
|
| 686 |
+
w2 = corrected_tokens[i+1]
|
| 687 |
+
|
| 688 |
+
w1_info = disambig_tokens[i].analyses[0].analysis if disambig_tokens[i].analyses else {}
|
| 689 |
+
w1_pos = w1_info.get('pos', 'unknown')
|
| 690 |
+
w1_num = w1_info.get('num', 's')
|
| 691 |
+
w1_gen = w1_info.get('gen', 'm')
|
| 692 |
+
|
| 693 |
+
# Heuristic override for duals (camel_tools sometimes gets them wrong)
|
| 694 |
+
if w1.endswith('تان') or w1.endswith('تين'):
|
| 695 |
+
w1_gen = 'f'
|
| 696 |
+
w1_num = 'd'
|
| 697 |
+
elif w1.endswith('ان') or w1.endswith('ين'):
|
| 698 |
+
if len(w1) > 4:
|
| 699 |
+
w1_num = 'd'
|
| 700 |
+
|
| 701 |
+
# Dual Adjective Agreement
|
| 702 |
+
if w1_num == 'd' and w1_pos in ['noun', 'unknown', 'noun_prop']:
|
| 703 |
+
base_adj = None
|
| 704 |
+
for suffix in ['ان', 'ين', 'تان', 'تين', 'ة', 'ون', 'ات', '']:
|
| 705 |
+
stem = w2[:-len(suffix)] if suffix else w2
|
| 706 |
+
if stem in MASC_TO_FEM_ADJ:
|
| 707 |
+
base_adj = stem
|
| 708 |
+
break
|
| 709 |
+
|
| 710 |
+
if base_adj:
|
| 711 |
+
is_nom = w1.endswith('ان') or w1.endswith('تان')
|
| 712 |
+
if w1_gen == 'f':
|
| 713 |
+
corrected_tokens[i+1] = base_adj + ('تان' if is_nom else 'تين')
|
| 714 |
+
else:
|
| 715 |
+
corrected_tokens[i+1] = base_adj + ('ان' if is_nom else 'ين')
|
| 716 |
+
|
| 717 |
+
# Plural Human Adjective Agreement
|
| 718 |
+
elif w1_num == 'p' and w1_pos in ['noun', 'unknown']:
|
| 719 |
+
base_adj = None
|
| 720 |
+
for suffix in ['ان', 'ين', 'تان', 'تين', 'ة', 'ون', 'ات', 'ين', '']:
|
| 721 |
+
stem = w2[:-len(suffix)] if suffix else w2
|
| 722 |
+
if stem in MASC_TO_FEM_ADJ:
|
| 723 |
+
base_adj = stem
|
| 724 |
+
break
|
| 725 |
+
|
| 726 |
+
if base_adj:
|
| 727 |
+
if w1.endswith('ون') or w1.endswith('ين') or w1_gen == 'm':
|
| 728 |
+
is_nom = w1.endswith('ون')
|
| 729 |
+
corrected_tokens[i+1] = base_adj + ('ون' if is_nom else 'ين')
|
| 730 |
+
elif w1.endswith('ات') or w1_gen == 'f':
|
| 731 |
+
corrected_tokens[i+1] = base_adj + 'ات'
|
| 732 |
+
|
| 733 |
+
return " ".join(corrected_tokens)
|
| 734 |
+
|
| 735 |
+
|
| 736 |
+
def process(self, original_text, generated_text):
|
| 737 |
+
"""Apply all grammar rules to model output."""
|
| 738 |
+
text = self.preserve_numbers(original_text, generated_text)
|
| 739 |
+
|
| 740 |
+
# ── Fix Hallucinated Subject Gender ──
|
| 741 |
+
# If model incorrectly changes female subject to male, restore it.
|
| 742 |
+
orig_words = original_text.split()
|
| 743 |
+
corr_words = text.split()
|
| 744 |
+
if len(orig_words) == len(corr_words):
|
| 745 |
+
for i, (o, c) in enumerate(zip(orig_words, corr_words)):
|
| 746 |
+
o_clean = o.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 747 |
+
c_clean = c.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 748 |
+
# If model dropped 'ة' from a word of length >= 4
|
| 749 |
+
if o_clean.endswith('ة') and not c_clean.endswith('ة') and o_clean[:-1] == c_clean:
|
| 750 |
+
corr_words[i] = o
|
| 751 |
+
text = " ".join(corr_words)
|
| 752 |
+
|
| 753 |
+
# Each rule is wrapped in try/except so that if camel-tools
|
| 754 |
+
# functions fail, the regex-based rules still execute.
|
| 755 |
+
for rule_name, rule_fn in [
|
| 756 |
+
('fix_demonstrative_agreement', self.fix_demonstrative_agreement),
|
| 757 |
+
('fix_number_and_gender_agreement', self.fix_number_and_gender_agreement),
|
| 758 |
+
('smart_asmaa_khamsa_fix', self.smart_asmaa_khamsa_fix),
|
| 759 |
+
('fix_verbs_nasb_and_jazm', self.fix_verbs_nasb_and_jazm),
|
| 760 |
+
('fix_gender_agreement', self.fix_gender_agreement),
|
| 761 |
+
('fix_noun_adjective_agreement_advanced', self.fix_noun_adjective_agreement_advanced),
|
| 762 |
+
('fix_prepositions_advanced', self.fix_prepositions_advanced),
|
| 763 |
+
('fix_subject_verb_agreement', self.fix_subject_verb_agreement),
|
| 764 |
+
('fix_kana_and_inna', self.fix_kana_and_inna),
|
| 765 |
+
('fix_conditional_sentences', self.fix_conditional_sentences),
|
| 766 |
+
('fix_tanween_fathah', self.fix_tanween_fathah),
|
| 767 |
+
('fix_initial_hamza', self.fix_initial_hamza),
|
| 768 |
+
('fix_suffix_hallucination', self.fix_suffix_hallucination),
|
| 769 |
+
('regex_rules_fallback', self.regex_rules_fallback),
|
| 770 |
+
]:
|
| 771 |
+
try:
|
| 772 |
+
text = rule_fn(text, original_text) if rule_name == 'fix_suffix_hallucination' else rule_fn(text)
|
| 773 |
+
except Exception as e:
|
| 774 |
+
logger.warning(f"[GRAMMAR-RULES] {rule_name} failed: {e}")
|
| 775 |
+
|
| 776 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 777 |
+
return text
|
| 778 |
+
|
| 779 |
+
def fix_suffix_hallucination(self, text, original_text):
|
| 780 |
+
"""
|
| 781 |
+
Revert grammar hallucination where extra consonants are appended to pronoun suffixes.
|
| 782 |
+
Example: شجعتهم → شجعتهمت
|
| 783 |
+
"""
|
| 784 |
+
orig_words = original_text.split()
|
| 785 |
+
curr_words = text.split()
|
| 786 |
+
|
| 787 |
+
if len(orig_words) == len(curr_words):
|
| 788 |
+
for i in range(len(orig_words)):
|
| 789 |
+
ow = orig_words[i]
|
| 790 |
+
cw = curr_words[i]
|
| 791 |
+
# If the current word is just the original word + 1 consonant, and original ended in a suffix
|
| 792 |
+
if len(cw) == len(ow) + 1 and cw.startswith(ow):
|
| 793 |
+
added_char = cw[-1]
|
| 794 |
+
if ow.endswith(('هم', 'هن', 'كم', 'كن', 'ها', 'نا')) and added_char in 'تمةنل':
|
| 795 |
+
curr_words[i] = ow
|
| 796 |
+
logger.info(f"[GRAMMAR-RULES] Reverted suffix hallucination: {cw} → {ow}")
|
| 797 |
+
text = ' '.join(curr_words)
|
| 798 |
+
return text
|
| 799 |
+
|
| 800 |
+
def fix_tanween_fathah(self, text):
|
| 801 |
+
"""
|
| 802 |
+
Add tanween fathah (ً) to indefinite accusative nouns ending in ا.
|
| 803 |
+
|
| 804 |
+
Arabic rule: Words like جدا, كثيرا, قرارا should be جداً, كثيراً, قراراً.
|
| 805 |
+
The trailing ا without tanween is a common orthographic error.
|
| 806 |
+
|
| 807 |
+
From legacy AraSpell._normalize_tanween_patterns():
|
| 808 |
+
Only apply to words >= 3 chars ending in ا where the ا is NOT part of
|
| 809 |
+
the root (e.g. NOT ما، إلى، على، أنا، هذا).
|
| 810 |
+
"""
|
| 811 |
+
# Common words ending in ا that should NOT get tanween
|
| 812 |
+
_NO_TANWEEN = {
|
| 813 |
+
'ما', 'إذا', 'هذا', 'أنا', 'إلى', 'على', 'حتى', 'متى', 'لما',
|
| 814 |
+
'إلا', 'أما', 'كما', 'ربما', 'مهما',
|
| 815 |
+
'عندما', 'بينما', 'حينما', 'كلما',
|
| 816 |
+
}
|
| 817 |
+
# Words that ALWAYS get tanween
|
| 818 |
+
_ALWAYS_TANWEEN = {
|
| 819 |
+
'جدا': 'جداً',
|
| 820 |
+
'كثيرا': 'كثيراً',
|
| 821 |
+
'شكرا': 'شكراً',
|
| 822 |
+
'نظرا': 'نظراً',
|
| 823 |
+
'قليلا': 'قليلاً',
|
| 824 |
+
'أيضا': 'أيضاً',
|
| 825 |
+
'فورا': 'فوراً',
|
| 826 |
+
'سابقا': 'سابقاً',
|
| 827 |
+
'لاحقا': 'لاحقاً',
|
| 828 |
+
'حاليا': 'حالياً',
|
| 829 |
+
'تقريبا': 'تقريباً',
|
| 830 |
+
'خصوصا': 'خصوصاً',
|
| 831 |
+
'عموما': 'عموماً',
|
| 832 |
+
'دائما': 'دائماً',
|
| 833 |
+
'مباشرا': 'مباشراً',
|
| 834 |
+
'أبدا': 'أبداً',
|
| 835 |
+
'غالبا': 'غالباً',
|
| 836 |
+
'أحيانا': 'أحياناً',
|
| 837 |
+
'مثلا': 'مثلاً',
|
| 838 |
+
'قرارا': 'قراراً',
|
| 839 |
+
'جديدا': 'جديداً',
|
| 840 |
+
'كبيرا': 'كبيراً',
|
| 841 |
+
'صغيرا': 'صغيراً',
|
| 842 |
+
'طويلا': 'طويلاً',
|
| 843 |
+
'قصيرا': 'قصيراً',
|
| 844 |
+
'سريعا': 'سريعاً',
|
| 845 |
+
'بطيئا': 'بطيئاً',
|
| 846 |
+
'جيدا': 'جيداً',
|
| 847 |
+
'سيئا': 'سيئاً',
|
| 848 |
+
'عظيما': 'عظيماً',
|
| 849 |
+
'قويا': 'قوياً',
|
| 850 |
+
'ضعيفا': 'ضعيفاً',
|
| 851 |
+
'صعبا': 'صعباً',
|
| 852 |
+
'سهلا': 'سهلاً',
|
| 853 |
+
'هاما': 'هاماً',
|
| 854 |
+
'نهائيا': 'نهائياً',
|
| 855 |
+
'رسميا': 'رسمياً',
|
| 856 |
+
'تماما': 'تماماً',
|
| 857 |
+
'عاجلا': 'عاجلاً',
|
| 858 |
+
'أولا': 'أولاً',
|
| 859 |
+
'ثانيا': 'ثانياً',
|
| 860 |
+
'ثالثا': 'ثالثاً',
|
| 861 |
+
'أخيرا': 'أخيراً',
|
| 862 |
+
'حقا': 'حقاً',
|
| 863 |
+
'حقيقيا': 'حقيقياً',
|
| 864 |
+
'علميا': 'علمياً',
|
| 865 |
+
'عمليا': 'عملياً',
|
| 866 |
+
}
|
| 867 |
+
words = text.split()
|
| 868 |
+
for i, w in enumerate(words):
|
| 869 |
+
if w in _ALWAYS_TANWEEN:
|
| 870 |
+
words[i] = _ALWAYS_TANWEEN[w]
|
| 871 |
+
return ' '.join(words)
|
| 872 |
+
|
| 873 |
+
def fix_initial_hamza(self, text):
|
| 874 |
+
"""
|
| 875 |
+
Fix missing hamza on initial alef for common verb/noun patterns.
|
| 876 |
+
|
| 877 |
+
Arabic rule: أفعل-pattern verbs and certain nouns require hamza:
|
| 878 |
+
- اعلن → أعلن (أَفْعَل form IV verb)
|
| 879 |
+
- اصدر → أصدر
|
| 880 |
+
- اسلم → أسلم
|
| 881 |
+
"""
|
| 882 |
+
# Common words where initial ا should be أ
|
| 883 |
+
_HAMZA_FIXES = {
|
| 884 |
+
'اعلن': 'أعلن', 'اعلنت': 'أعلنت', 'اعلنوا': 'أعلنوا',
|
| 885 |
+
'اصدر': 'أصدر', 'اصدرت': 'أصدرت', 'اصدروا': 'أصدروا',
|
| 886 |
+
'اسلم': 'أسلم', 'اسلمت': 'أسلمت', 'اسلموا': 'أسلموا',
|
| 887 |
+
'اكد': 'أكد', 'اكدت': 'أكدت', 'اكدوا': 'أكدوا',
|
| 888 |
+
'اعطى': 'أعطى', 'اعطت': 'أعطت', 'اعطوا': 'أعطوا',
|
| 889 |
+
'انجز': 'أنجز', 'انجزت': 'أنجزت', 'انجزوا': 'أنجزوا',
|
| 890 |
+
'ارسل': 'أرسل', 'ارسلت': 'أرسلت', 'ارسلوا': 'أرسلوا',
|
| 891 |
+
'اخرج': 'أخرج', 'اخرجت': 'أخرجت', 'اخرجوا': 'أخرجوا',
|
| 892 |
+
'انشأ': 'أنشأ', 'انشأت': 'أنشأت', 'انشأوا': 'أنشأوا',
|
| 893 |
+
'اضاف': 'أضاف', 'اضافت': 'أضافت', 'اضافوا': 'أضافوا',
|
| 894 |
+
'احب': 'أحب', 'احبت': 'أحبت', 'احبوا': 'أحبوا',
|
| 895 |
+
'افهم': 'أفهم', 'افهمت': 'أفهمت', 'افهموا': 'أفهموا',
|
| 896 |
+
'اعجب': 'أعجب', 'اعجبت': 'أعجبت', 'اعجبوا': 'أعجبوا',
|
| 897 |
+
'اكرم': 'أكرم', 'اكرمت': 'أكرمت', 'اكرموا': 'أكرموا',
|
| 898 |
+
'انقذ': 'أنقذ', 'انقذت': 'أنقذت', 'انقذوا': 'أنقذوا',
|
| 899 |
+
'الامهات': 'الأمهات', 'الاطفال': 'الأطفال',
|
| 900 |
+
'الامة': 'الأمة', 'الاستاذ': 'الأستاذ',
|
| 901 |
+
'ايضا': 'أيضا',
|
| 902 |
+
'اول': 'أول',
|
| 903 |
+
}
|
| 904 |
+
# Context-dependent إنّ/أنّ hamza: kasra (إ) at sentence start, fathah (أ) mid-sentence
|
| 905 |
+
_INNA_SENTENCE_INITIAL = {
|
| 906 |
+
'ان': 'إن', 'انه': 'إنه', 'انها': 'إنها',
|
| 907 |
+
'اننا': 'إننا', 'انهم': 'إنهم', 'انك': 'إنك', 'انكم': 'إنكم',
|
| 908 |
+
}
|
| 909 |
+
_ANNA_MID_SENTENCE = {
|
| 910 |
+
'ان': 'أن', 'انه': 'أنه', 'انها': 'أنها',
|
| 911 |
+
'اننا': 'أننا', 'انهم': 'أنهم', 'انك': 'أنك', 'انكم': 'أنكم',
|
| 912 |
+
}
|
| 913 |
+
_HAMZA_STEMS = {
|
| 914 |
+
'احب': 'أحب', 'افهم': 'أفهم', 'اعلن': 'أعلن',
|
| 915 |
+
'اصدر': 'أصدر', 'اسلم': 'أسلم', 'اكد': 'أكد',
|
| 916 |
+
'انجز': 'أنجز', 'ارسل': 'أرسل', 'اخرج': 'أخرج',
|
| 917 |
+
'اضاف': 'أضاف', 'اعجب': 'أعجب', 'اكرم': 'أكرم',
|
| 918 |
+
'انقذ': 'أنقذ',
|
| 919 |
+
}
|
| 920 |
+
_PRONOUN_SUFFIXES = {'ه', 'ها', 'ك', 'كم', 'كن', 'هم', 'هن', 'ني', 'نا'}
|
| 921 |
+
words = text.split()
|
| 922 |
+
for i, w in enumerate(words):
|
| 923 |
+
if w in _HAMZA_FIXES:
|
| 924 |
+
words[i] = _HAMZA_FIXES[w]
|
| 925 |
+
continue
|
| 926 |
+
# إنّ/أنّ: kasra at sentence start, fathah mid-sentence
|
| 927 |
+
_is_sent_start = (i == 0) or (words[i-1][-1] in '.؟!؛' if words[i-1] else False)
|
| 928 |
+
if _is_sent_start and w in _INNA_SENTENCE_INITIAL:
|
| 929 |
+
words[i] = _INNA_SENTENCE_INITIAL[w]
|
| 930 |
+
continue
|
| 931 |
+
if not _is_sent_start and w in _ANNA_MID_SENTENCE:
|
| 932 |
+
words[i] = _ANNA_MID_SENTENCE[w]
|
| 933 |
+
continue
|
| 934 |
+
for stem, fixed in _HAMZA_STEMS.items():
|
| 935 |
+
if w.startswith(stem) and len(w) > len(stem):
|
| 936 |
+
suffix = w[len(stem):]
|
| 937 |
+
if suffix in _PRONOUN_SUFFIXES:
|
| 938 |
+
words[i] = fixed + suffix
|
| 939 |
+
break
|
| 940 |
+
return ' '.join(words)
|
old_rules.py
ADDED
|
@@ -0,0 +1,1606 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AraSpell — Arabic Spell Checker Pipeline (Rules & Classes)
|
| 2 |
+
# Extracted from AraSpell.py — NO global model loading, NO Gradio dependencies.
|
| 3 |
+
# All classes are imported by araspell_service.py.
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
import math
|
| 7 |
+
import logging
|
| 8 |
+
import torch
|
| 9 |
+
from collections import Counter
|
| 10 |
+
from enum import Enum
|
| 11 |
+
from typing import List, Tuple, Optional
|
| 12 |
+
|
| 13 |
+
import Levenshtein
|
| 14 |
+
import jellyfish
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 19 |
+
# ERROR TYPE ENUM
|
| 20 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
class ErrorType(Enum):
|
| 23 |
+
"""Types of spelling errors"""
|
| 24 |
+
CHAR_REPETITION = "char_repetition"
|
| 25 |
+
WORD_MERGE = "word_merge"
|
| 26 |
+
CHAR_SUBSTITUTION = "char_substitution"
|
| 27 |
+
MIXED = "mixed"
|
| 28 |
+
CLEAN = "clean"
|
| 29 |
+
|
| 30 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 31 |
+
# KEYBOARD PROXIMITY (Phase 12 — from original AraSpell.py L475-520)
|
| 32 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 33 |
+
|
| 34 |
+
class RulesBasedCorrector:
|
| 35 |
+
"""Arabic keyboard-proximity and character substitution rules."""
|
| 36 |
+
|
| 37 |
+
# Arabic keyboard layout adjacency mapping
|
| 38 |
+
KEYBOARD_NEIGHBORS = {
|
| 39 |
+
'ض': ['ص', 'ق'],
|
| 40 |
+
'ص': ['ض', 'ث', 'ق'],
|
| 41 |
+
'ث': ['ص', 'ق'],
|
| 42 |
+
'ق': ['ض', 'ص', 'ث', 'ف', 'غ'],
|
| 43 |
+
'ف': ['ق', 'غ', 'ع', 'ب'],
|
| 44 |
+
'غ': ['ق', 'ف', 'ع', 'ه'],
|
| 45 |
+
'ع': ['ف', 'غ', 'ه', 'خ'],
|
| 46 |
+
'ه': ['غ', 'ع', 'خ', 'ح'],
|
| 47 |
+
'خ': ['ع', 'ه', 'ح', 'ج'],
|
| 48 |
+
'ح': ['ه', 'خ', 'ج'],
|
| 49 |
+
'ج': ['خ', 'ح', 'د'],
|
| 50 |
+
'د': ['ج', 'ذ'],
|
| 51 |
+
'ذ': ['د'],
|
| 52 |
+
'ش': ['س', 'ي', 'ئ'],
|
| 53 |
+
'س': ['ش', 'ي', 'ب'],
|
| 54 |
+
'ي': ['ش', 'س', 'ب', 'ت'],
|
| 55 |
+
'ب': ['ي', 'س', 'ف', 'ل', 'ن'],
|
| 56 |
+
'ل': ['ب', 'ا', 'ن', 'م'],
|
| 57 |
+
'ا': ['ل', 'ت', 'م'],
|
| 58 |
+
'ت': ['ي', 'ا', 'ن'],
|
| 59 |
+
'ن': ['ب', 'ل', 'ت', 'م', 'ك'],
|
| 60 |
+
'م': ['ل', 'ا', 'ن', 'ك'],
|
| 61 |
+
'ك': ['ن', 'م', 'ط'],
|
| 62 |
+
'ط': ['ك', 'ظ'],
|
| 63 |
+
'ظ': ['ط'],
|
| 64 |
+
'ئ': ['ش', 'ء', 'ر'],
|
| 65 |
+
'ء': ['ئ', 'ؤ'],
|
| 66 |
+
'ؤ': ['ء', 'ر'],
|
| 67 |
+
'ر': ['ئ', 'ؤ', 'لا', 'ى', 'ز'],
|
| 68 |
+
'لا': ['ر', 'ى'],
|
| 69 |
+
'ى': ['ر', 'لا', 'ة', 'ز'],
|
| 70 |
+
'ة': ['ى', 'و', 'ز'],
|
| 71 |
+
'و': ['ة', 'ز'],
|
| 72 |
+
'ز': ['ر', 'ى', 'ة', 'و'],
|
| 73 |
+
'أ': ['ا', 'إ', 'آ'],
|
| 74 |
+
'إ': ['ا', 'أ'],
|
| 75 |
+
'آ': ['ا', 'أ'],
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
@staticmethod
|
| 79 |
+
def is_keyboard_neighbor(char1: str, char2: str) -> bool:
|
| 80 |
+
"""Check if two Arabic chars are adjacent on the keyboard."""
|
| 81 |
+
neighbors = RulesBasedCorrector.KEYBOARD_NEIGHBORS.get(char1, [])
|
| 82 |
+
return char2 in neighbors
|
| 83 |
+
|
| 84 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 85 |
+
# POST PROCESSOR
|
| 86 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 87 |
+
|
| 88 |
+
class AraSpellPostProcessor:
|
| 89 |
+
"""Arabic text post-processing techniques."""
|
| 90 |
+
|
| 91 |
+
ARABIC_HARAKAT = 'ًٌٍَُِّْ'
|
| 92 |
+
TATWEEL = 'ـ'
|
| 93 |
+
NORMALIZER_MAP = {
|
| 94 |
+
'ﻹ': 'لإ', 'ﻷ': 'لأ', 'ﻵ': 'لآ', 'ﻻ': 'لا', 'ﷲ': 'الله'
|
| 95 |
+
}
|
| 96 |
+
ARABIC_CONSONANTS = set('بتثجحخدذرزسشصضطظعغفقكلمن')
|
| 97 |
+
|
| 98 |
+
# --- Basic Normalization ---
|
| 99 |
+
|
| 100 |
+
@staticmethod
|
| 101 |
+
def remove_harakat(text: str) -> str:
|
| 102 |
+
"""Remove Arabic diacritics"""
|
| 103 |
+
return re.sub(r'[ً-ْ]', '', text)
|
| 104 |
+
|
| 105 |
+
@staticmethod
|
| 106 |
+
def remove_tatweel(text: str) -> str:
|
| 107 |
+
"""Remove Arabic kashida/tatweel"""
|
| 108 |
+
return text.replace(AraSpellPostProcessor.TATWEEL, '')
|
| 109 |
+
|
| 110 |
+
@staticmethod
|
| 111 |
+
def normalize_special_chars(text: str) -> str:
|
| 112 |
+
"""Normalize special Arabic ligatures"""
|
| 113 |
+
for old, new in AraSpellPostProcessor.NORMALIZER_MAP.items():
|
| 114 |
+
text = text.replace(old, new)
|
| 115 |
+
return text
|
| 116 |
+
|
| 117 |
+
# --- Core Functions ---
|
| 118 |
+
|
| 119 |
+
@staticmethod
|
| 120 |
+
def unified_collapse_repeated(text: str) -> str:
|
| 121 |
+
"""
|
| 122 |
+
Collapse repeated characters.
|
| 123 |
+
Arabic: 3+ consecutive → 1 | Latin: 2+ consecutive → 1
|
| 124 |
+
"""
|
| 125 |
+
text = re.sub(r"([\u0600-\u06FF])\1{2,}", r"\1", text)
|
| 126 |
+
text = re.sub(r"([a-zA-Z])\1+", r"\1", text)
|
| 127 |
+
return text
|
| 128 |
+
|
| 129 |
+
@staticmethod
|
| 130 |
+
def remove_duplicate_words(text: str) -> str:
|
| 131 |
+
"""Remove consecutive duplicate words. e.g. كتاب كتاب → كتاب"""
|
| 132 |
+
words = text.split()
|
| 133 |
+
if len(words) < 2:
|
| 134 |
+
return text
|
| 135 |
+
result = [words[0]]
|
| 136 |
+
for i in range(1, len(words)):
|
| 137 |
+
if words[i] != words[i-1]:
|
| 138 |
+
result.append(words[i])
|
| 139 |
+
return ' '.join(result)
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
def normalize_spaces(text: str) -> str:
|
| 143 |
+
"""Normalize whitespace: multiple spaces, unicode spaces, punctuation spacing."""
|
| 144 |
+
text = re.sub(r' +', ' ', text)
|
| 145 |
+
text = text.replace('\u00A0', ' ')
|
| 146 |
+
text = text.replace('\u200B', '')
|
| 147 |
+
text = text.replace('\u200C', '')
|
| 148 |
+
text = text.replace('\u200D', '')
|
| 149 |
+
text = text.strip()
|
| 150 |
+
text = re.sub(r'\s*([،؛؟!.])\s*', r'\1 ', text)
|
| 151 |
+
text = text.strip()
|
| 152 |
+
return text
|
| 153 |
+
|
| 154 |
+
@staticmethod
|
| 155 |
+
def remove_word_repetition_with_wa(text: str) -> str:
|
| 156 |
+
"""Remove word و word → word"""
|
| 157 |
+
# Bug 2.9: This deletes valid rhetorical repetition (التوكيد اللفظي) like "صنفا وصنفا"
|
| 158 |
+
# Disabled as it is highly destructive to valid Arabic.
|
| 159 |
+
return text
|
| 160 |
+
|
| 161 |
+
# --- Hamza & Ta Marbuta Handling ---
|
| 162 |
+
|
| 163 |
+
# Common Arabic words with hamza errors — covers the most frequent
|
| 164 |
+
# spelling mistakes in informal Arabic writing
|
| 165 |
+
HAMZA_WHITELIST = {
|
| 166 |
+
'الي': 'إلى', 'الى': 'إلى',
|
| 167 |
+
'انت': 'أنت', 'انتم': 'أنتم', 'انتي': 'أنتِ',
|
| 168 |
+
'انتو': 'أنتم', 'انتن': 'أنتن',
|
| 169 |
+
'انا': 'أنا',
|
| 170 |
+
'امس': 'أمس',
|
| 171 |
+
'لان': 'لأن', 'لانه': 'لأنه', 'لانها': 'لأنها',
|
| 172 |
+
'لانهم': 'لأنهم', 'لانك': 'لأنك',
|
| 173 |
+
'اذا': 'إذا', 'اذ': 'إذ',
|
| 174 |
+
'اي': 'أي', 'اين': 'أين',
|
| 175 |
+
'او': 'أو',
|
| 176 |
+
|
| 177 |
+
'ان': 'أن', 'انه': 'أنه', 'انها': 'أنها', 'انهم': 'أنهم',
|
| 178 |
+
'اخر': 'آخر', 'اخرى': 'أخرى',
|
| 179 |
+
'الان': 'الآن',
|
| 180 |
+
'اول': 'أول', 'اولى': 'أولى',
|
| 181 |
+
'اصبح': 'أصبح', 'اصبحت': 'أصبحت',
|
| 182 |
+
'اكثر': 'أكثر', 'اقل': 'أقل',
|
| 183 |
+
'اعلى': 'أعلى', 'ادنى': 'أدنى',
|
| 184 |
+
'اسرع': 'أسرع', 'ابطا': 'أبطأ',
|
| 185 |
+
'اكبر': 'أكبر', 'اصغر': 'أصغر',
|
| 186 |
+
'احسن': 'أحسن', 'اسوا': 'أسوأ',
|
| 187 |
+
'امام': 'أمام',
|
| 188 |
+
'اثناء': 'أثناء',
|
| 189 |
+
'ايضا': 'أيضاً', 'ايض': 'أيضاً',
|
| 190 |
+
'اساسي': 'أساسي', 'اساسية': 'أساسية',
|
| 191 |
+
'اخي': 'أخي', 'اخت': 'أخت', 'اخو': 'أخو',
|
| 192 |
+
'ابي': 'أبي', 'اب': 'أب', 'ابو': 'أبو',
|
| 193 |
+
'اهل': 'أهل',
|
| 194 |
+
'اطفال': 'أطفال',
|
| 195 |
+
'اصدقاء': 'أصدقاء', 'اصدقائي': 'أصدقائي',
|
| 196 |
+
'اريد': 'أريد', 'احب': 'أحب',
|
| 197 |
+
'اعلم': 'أعلم',
|
| 198 |
+
'اكل': 'أكل',
|
| 199 |
+
'الايام': 'الأيام',
|
| 200 |
+
'الاطفال': 'الأطفال',
|
| 201 |
+
'الاسعار': 'الأسعار',
|
| 202 |
+
'الاولى': 'الأولى',
|
| 203 |
+
'الاخير': 'الأخير', 'الاخيرة': 'الأخيرة',
|
| 204 |
+
'واصدقائي': 'وأصدقائي',
|
| 205 |
+
# FIX-14: Additional hamza entries
|
| 206 |
+
'ابناء': 'أبناء',
|
| 207 |
+
'اجمل': 'أجمل', 'اجمع': 'أجمع',
|
| 208 |
+
'اعلن': 'أعلن', 'اعلنت': 'أعلنت',
|
| 209 |
+
'اكد': 'أكد', 'اكدت': 'أكدت',
|
| 210 |
+
'اشار': 'أشار', 'اشارت': 'أشارت',
|
| 211 |
+
'ارسل': 'أرسل', 'ارسلت': 'أرسلت',
|
| 212 |
+
'اضاف': 'أضاف', 'اضافت': 'أضافت',
|
| 213 |
+
'اخيرا': 'أخيراً', 'اخيراً': 'أخيراً',
|
| 214 |
+
'اساسا': 'أساساً', 'اساساً': 'أساساً',
|
| 215 |
+
'احيانا': 'أحياناً', 'احياناً': 'أحياناً',
|
| 216 |
+
'ابدا': 'أبداً', 'ابداً': 'أبداً',
|
| 217 |
+
'اصلا': 'أصلاً', 'اصلاً': 'أصلاً',
|
| 218 |
+
'اخبار': 'أخبار', 'اخبر': 'أخبر',
|
| 219 |
+
'امر': 'أمر', 'امور': 'أمور',
|
| 220 |
+
'اهم': 'أهم', 'اهمية': 'أهمية',
|
| 221 |
+
'اصبح': 'أصبح', 'اصل': 'أصل',
|
| 222 |
+
'اثر': 'أثر', 'اثار': 'آثار',
|
| 223 |
+
'اساء': 'أساء', 'اساس': 'أساس',
|
| 224 |
+
'استاذ': 'أستاذ', 'اسلام': 'إسلام',
|
| 225 |
+
# Batch 3: More hamza entries for remaining FN cases
|
| 226 |
+
'اسرة': 'أسرة', 'اسر': 'أسر',
|
| 227 |
+
'اعضاء': 'أعضاء', 'اعداد': 'أعداد',
|
| 228 |
+
'اعمال': 'أعمال', 'اعمار': 'أعما��',
|
| 229 |
+
'انجاز': 'إنجاز', 'انجازات': 'إنجازات',
|
| 230 |
+
'انشاء': 'إنشاء', 'انتاج': 'إنتاج',
|
| 231 |
+
'انتخابات': 'انتخابات', 'انتظار': 'انتظار',
|
| 232 |
+
'اسلامي': 'إسلامي', 'اسلامية': 'إسلامية',
|
| 233 |
+
'امكانية': 'إمكانية', 'امكان': 'إمكان',
|
| 234 |
+
'اشكالية': 'إشكالية',
|
| 235 |
+
'ادارة': 'إدارة', 'ادارية': 'إدارية',
|
| 236 |
+
'اعلام': 'إعلام', 'اعلامي': 'إعلامي',
|
| 237 |
+
'احتمال': 'احتمال', 'احتفال': 'احتفال',
|
| 238 |
+
'اقرا': 'أقرأ', 'اقرأ': 'أقرأ',
|
| 239 |
+
'اسافر': 'أسافر',
|
| 240 |
+
'احبه': 'أحبه',
|
| 241 |
+
'مسؤول': 'مسؤول', 'مسؤولية': 'مسؤولية',
|
| 242 |
+
'رؤية': 'رؤية', 'رؤيا': 'رؤيا',
|
| 243 |
+
'مؤسسة': 'مؤسسة', 'مؤتمر': 'مؤتمر',
|
| 244 |
+
'تأثير': 'تأثير', 'تأكيد': 'تأكيد',
|
| 245 |
+
'البنايه': 'البناية',
|
| 246 |
+
'جدا': 'جداً', 'جداً': 'جداً',
|
| 247 |
+
# FIX-14: Alif maqsura common errors
|
| 248 |
+
'المستشفي': 'المستشفى',
|
| 249 |
+
'مصطفي': 'مصطفى', 'موسي': 'موسى', 'عيسي': 'عيسى',
|
| 250 |
+
'هدي': 'هدى', 'بني': 'بنى',
|
| 251 |
+
'معني': 'معنى', 'مبني': 'مبنى',
|
| 252 |
+
|
| 253 |
+
'الي': 'إلى',
|
| 254 |
+
# FIX-47: Verb+pronoun hamza entries (احبه→أحبه)
|
| 255 |
+
'احبه': 'أحبه', 'احبها': 'أحبها', 'احبك': 'أحبك',
|
| 256 |
+
'احبكم': 'أحبكم', 'احببت': 'أحببت',
|
| 257 |
+
'افهم': 'أفهم', 'افهمه': 'أفهمه', 'افهمها': 'أفهمها',
|
| 258 |
+
'افهمك': 'أفهمك',
|
| 259 |
+
'اعطي': 'أعطي', 'اعطاه': 'أعطاه', 'اعطاها': 'أعطاها',
|
| 260 |
+
'اعطى': 'أعطى', 'اعطت': 'أعطت', 'اعطيت': 'أعطيت',
|
| 261 |
+
'احتاج': 'أحتاج', 'احتاجه': 'أحتاجه',
|
| 262 |
+
'استطيع': 'أستطيع', 'استطع': 'أستطع',
|
| 263 |
+
'اتمنى': 'أتمنى', 'اتوقع': 'أتوقع',
|
| 264 |
+
'اشعر': 'أشعر', 'اظن': 'أظن', 'افضل': 'أفضل',
|
| 265 |
+
'اخاف': 'أخاف', 'اتذكر': 'أتذكر', 'اتعلم': 'أتعلم',
|
| 266 |
+
'ارجو': 'أرجو', 'اتوقف': 'أتوقف', 'انصح': 'أنصح',
|
| 267 |
+
'انسان': 'إنسان', 'انسانية': 'إنسانية',
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
@staticmethod
|
| 271 |
+
def fix_hamza_conservative(text: str) -> str:
|
| 272 |
+
"""Conservative Hamza normalization — only at word END, not middle."""
|
| 273 |
+
# Bug 2.5: Blindly changing أ at the end of word to ا corrupts valid orthography (قرأ -> قرا)
|
| 274 |
+
# Disabled as it is highly destructive.
|
| 275 |
+
return text
|
| 276 |
+
|
| 277 |
+
# Attached prefixes that can precede hamza-whitelist words
|
| 278 |
+
# Ordered longest-first so وال is tried before و
|
| 279 |
+
HAMZA_PREFIXES = ['وبال', 'فبال', 'وال', 'بال', 'فال', 'كال', 'ول', 'فل',
|
| 280 |
+
'وب', 'فب', 'وك', 'فك', 'و', 'ف', 'ب', 'ك', 'ل']
|
| 281 |
+
|
| 282 |
+
@staticmethod
|
| 283 |
+
def fix_common_hamza(text: str) -> str:
|
| 284 |
+
"""
|
| 285 |
+
Fix common hamza placement errors using a whitelist.
|
| 286 |
+
Also handles prefixed words: و/ف/ب/ك/ل + whitelist word.
|
| 287 |
+
Handles adjacent punctuation (e.g. واصدقائي، → وأصدقائي،)
|
| 288 |
+
"""
|
| 289 |
+
words = text.split()
|
| 290 |
+
result = []
|
| 291 |
+
for word in words:
|
| 292 |
+
# Separate leading/trailing punctuation from the core word
|
| 293 |
+
match = re.match(r'^([\.,،؛؟!:;?\(\)\[\]«»"\'\s]*)(.*?)([\.,،؛؟!:;?\(\)\[\]«»"\'\s]*)$', word)
|
| 294 |
+
if not match or not match.group(2):
|
| 295 |
+
result.append(word)
|
| 296 |
+
continue
|
| 297 |
+
|
| 298 |
+
lead_punct = match.group(1)
|
| 299 |
+
core_word = match.group(2)
|
| 300 |
+
trail_punct = match.group(3)
|
| 301 |
+
|
| 302 |
+
# Check exact match first
|
| 303 |
+
if core_word in AraSpellPostProcessor.HAMZA_WHITELIST:
|
| 304 |
+
result.append(lead_punct + AraSpellPostProcessor.HAMZA_WHITELIST[core_word] + trail_punct)
|
| 305 |
+
continue
|
| 306 |
+
|
| 307 |
+
# Try stripping common prefixes and looking up the remainder
|
| 308 |
+
fixed = False
|
| 309 |
+
for prefix in AraSpellPostProcessor.HAMZA_PREFIXES:
|
| 310 |
+
if core_word.startswith(prefix) and len(core_word) > len(prefix) + 1:
|
| 311 |
+
remainder = core_word[len(prefix):]
|
| 312 |
+
if remainder in AraSpellPostProcessor.HAMZA_WHITELIST:
|
| 313 |
+
result.append(lead_punct + prefix + AraSpellPostProcessor.HAMZA_WHITELIST[remainder] + trail_punct)
|
| 314 |
+
fixed = True
|
| 315 |
+
break
|
| 316 |
+
if not fixed:
|
| 317 |
+
result.append(word)
|
| 318 |
+
return ' '.join(result)
|
| 319 |
+
|
| 320 |
+
@staticmethod
|
| 321 |
+
def fix_ha_ta_marbuta(text: str, vocab_manager=None) -> str:
|
| 322 |
+
"""
|
| 323 |
+
Smart ه → ة fix at end of words.
|
| 324 |
+
Strategy: Always prefer ة when the previous char is a consonant,
|
| 325 |
+
UNLESS the ه form is specifically a known word and the ة form is NOT.
|
| 326 |
+
"""
|
| 327 |
+
PROTECTED_ENDINGS = ['لله']
|
| 328 |
+
# Words that genuinely end in ه (not ة)
|
| 329 |
+
PROTECTED_HA_WORDS = {
|
| 330 |
+
'الله', 'لله', 'فيه', 'عليه', 'منه', 'به', 'له', 'إليه',
|
| 331 |
+
'وجه', 'نزه', 'سفه', 'فقه', 'نبه', 'شبه', 'مكره', 'تنبه',
|
| 332 |
+
'اتجه', 'توجه', 'تشابه',
|
| 333 |
+
}
|
| 334 |
+
words = text.split()
|
| 335 |
+
result = []
|
| 336 |
+
for word in words:
|
| 337 |
+
if any(word.endswith(e) for e in PROTECTED_ENDINGS):
|
| 338 |
+
result.append(word)
|
| 339 |
+
continue
|
| 340 |
+
if word in PROTECTED_HA_WORDS:
|
| 341 |
+
result.append(word)
|
| 342 |
+
continue
|
| 343 |
+
if len(word) >= 3 and word.endswith('ه'):
|
| 344 |
+
if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS:
|
| 345 |
+
candidate_with_ta = word[:-1] + 'ة'
|
| 346 |
+
# Default: prefer ة (correct Arabic orthography for feminine nouns)
|
| 347 |
+
if vocab_manager:
|
| 348 |
+
ta_iv = vocab_manager.is_iv(candidate_with_ta)
|
| 349 |
+
ha_iv = vocab_manager.is_iv(word)
|
| 350 |
+
if ha_iv and ta_iv:
|
| 351 |
+
# Bug 2.2: Do not prefer ة if ه is also valid (possessive pronoun)
|
| 352 |
+
result.append(word)
|
| 353 |
+
continue
|
| 354 |
+
elif ta_iv:
|
| 355 |
+
# Prefer ة when ONLY the ة form is valid
|
| 356 |
+
result.append(candidate_with_ta)
|
| 357 |
+
continue
|
| 358 |
+
elif ha_iv:
|
| 359 |
+
result.append(word)
|
| 360 |
+
continue
|
| 361 |
+
# No vocab manager — default to ة
|
| 362 |
+
result.append(candidate_with_ta)
|
| 363 |
+
continue
|
| 364 |
+
result.append(word)
|
| 365 |
+
return ' '.join(result)
|
| 366 |
+
|
| 367 |
+
# --- Hallucination Removal ---
|
| 368 |
+
|
| 369 |
+
@staticmethod
|
| 370 |
+
def remove_hallucinations(text: str) -> str:
|
| 371 |
+
"""Remove model hallucinations: duplicate words, trailing 'و' artifacts."""
|
| 372 |
+
words = text.split()
|
| 373 |
+
if not words:
|
| 374 |
+
return text
|
| 375 |
+
result = []
|
| 376 |
+
i = 0
|
| 377 |
+
|
| 378 |
+
def normalize_word(w: str) -> str:
|
| 379 |
+
w = w.replace('ال', '').replace('ة', 'ه')
|
| 380 |
+
w = re.sub(r'[أإآ]', 'ا', w)
|
| 381 |
+
return w
|
| 382 |
+
|
| 383 |
+
while i < len(words):
|
| 384 |
+
word = words[i]
|
| 385 |
+
if len(word) > 4 and word.endswith('و'):
|
| 386 |
+
prev_char = word[-2]
|
| 387 |
+
if prev_char in 'ةهاأإآء':
|
| 388 |
+
word = word[:-1]
|
| 389 |
+
if i + 1 < len(words):
|
| 390 |
+
next_word = words[i + 1]
|
| 391 |
+
# Bug 2.11: Destroys Badal structures (الأستاذ أستاذ -> الأستاذ)
|
| 392 |
+
if word == next_word: # Only remove exact duplicates, not normalized duplicates
|
| 393 |
+
keep = next_word if next_word.startswith('ال') and not word.startswith('ال') else word
|
| 394 |
+
result.append(keep)
|
| 395 |
+
i += 2
|
| 396 |
+
continue
|
| 397 |
+
result.append(word)
|
| 398 |
+
i += 1
|
| 399 |
+
return ' '.join(result)
|
| 400 |
+
|
| 401 |
+
@staticmethod
|
| 402 |
+
def remove_hallucinated_prefix(text: str, original: str) -> str:
|
| 403 |
+
"""Remove particles (و/في) added by model if not in original"""
|
| 404 |
+
if not original:
|
| 405 |
+
return text
|
| 406 |
+
if text.startswith('و ') and not original.startswith('و'):
|
| 407 |
+
rest = text[2:].strip()
|
| 408 |
+
if AraSpellPostProcessor.normalize_special_chars(rest) == AraSpellPostProcessor.normalize_special_chars(original):
|
| 409 |
+
return rest
|
| 410 |
+
return text
|
| 411 |
+
|
| 412 |
+
# --- Word Splitting & Merging ---
|
| 413 |
+
|
| 414 |
+
@staticmethod
|
| 415 |
+
def merge_separated_al(text: str) -> str:
|
| 416 |
+
"""Merge 'ال' separated by space: ال + كتاب → الكتاب"""
|
| 417 |
+
return re.sub(r'\bال\s+(\w+)', r'ال\1', text)
|
| 418 |
+
|
| 419 |
+
@staticmethod
|
| 420 |
+
def join_fragments(text: str) -> str:
|
| 421 |
+
"""Join short fragments with validation."""
|
| 422 |
+
words = text.split()
|
| 423 |
+
if len(words) < 2:
|
| 424 |
+
return text
|
| 425 |
+
STANDALONE_WORDS = {
|
| 426 |
+
'من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى', 'حتى', 'منذ', 'خلال',
|
| 427 |
+
'بعد', 'قبل', 'ب', 'ل', 'ك', 'و', 'أو', 'لا', 'ما', 'لم', 'لن',
|
| 428 |
+
'هو', 'هي', 'هم', 'أن', 'إن', 'كل', 'كان', 'قد', 'قال', 'ذلك',
|
| 429 |
+
'هذا', 'هذه', 'تلك', 'التي', 'الذي', 'التى', 'اللذي'
|
| 430 |
+
}
|
| 431 |
+
result = []
|
| 432 |
+
i = 0
|
| 433 |
+
while i < len(words):
|
| 434 |
+
word = words[i]
|
| 435 |
+
if i + 1 < len(words):
|
| 436 |
+
next_word = words[i + 1]
|
| 437 |
+
if word in STANDALONE_WORDS and next_word in STANDALONE_WORDS:
|
| 438 |
+
result.append(word)
|
| 439 |
+
i += 1
|
| 440 |
+
continue
|
| 441 |
+
if len(next_word) == 1:
|
| 442 |
+
result.append(word + next_word)
|
| 443 |
+
i += 2
|
| 444 |
+
continue
|
| 445 |
+
# Bug 2.3: Destructive word merging (يوم مشمس -> يومشمس)
|
| 446 |
+
# Removed generic boundary letter merging.
|
| 447 |
+
result.append(word)
|
| 448 |
+
i += 1
|
| 449 |
+
return ' '.join(result)
|
| 450 |
+
|
| 451 |
+
# --- Main Pipelines ---
|
| 452 |
+
|
| 453 |
+
@staticmethod
|
| 454 |
+
def full_postprocess(text: str, original: str = "", vocab_manager=None) -> str:
|
| 455 |
+
"""Apply all post-processing steps."""
|
| 456 |
+
if original:
|
| 457 |
+
text = AraSpellPostProcessor.remove_hallucinated_prefix(text, original)
|
| 458 |
+
text = AraSpellPostProcessor.normalize_special_chars(text)
|
| 459 |
+
text = AraSpellPostProcessor.remove_hallucinations(text)
|
| 460 |
+
text = AraSpellPostProcessor.unified_collapse_repeated(text)
|
| 461 |
+
text = AraSpellPostProcessor.fix_hamza_conservative(text)
|
| 462 |
+
text = AraSpellPostProcessor.fix_common_hamza(text) # Fix S3: hamza whitelist
|
| 463 |
+
text = AraSpellPostProcessor.fix_ha_ta_marbuta(text, vocab_manager=vocab_manager)
|
| 464 |
+
text = AraSpellPostProcessor.remove_word_repetition_with_wa(text)
|
| 465 |
+
text = AraSpellPostProcessor.remove_duplicate_words(text)
|
| 466 |
+
text = AraSpellPostProcessor.normalize_spaces(text)
|
| 467 |
+
return text
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 471 |
+
# ERROR CLASSIFIER
|
| 472 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 473 |
+
|
| 474 |
+
class ErrorClassifier:
|
| 475 |
+
"""Classify type of spelling error"""
|
| 476 |
+
|
| 477 |
+
NON_ARABIC_KEYBOARD = set('پگچژکەڕڤڵڎےۀۃھیټډڼڑ')
|
| 478 |
+
|
| 479 |
+
@staticmethod
|
| 480 |
+
def has_char_substitution(text: str) -> bool:
|
| 481 |
+
return any(c in ErrorClassifier.NON_ARABIC_KEYBOARD for c in text)
|
| 482 |
+
|
| 483 |
+
@staticmethod
|
| 484 |
+
def has_char_repetition(text: str, threshold: int = 3) -> bool:
|
| 485 |
+
return bool(re.search(r"(.)\1{" + str(threshold - 1) + ",}", text))
|
| 486 |
+
|
| 487 |
+
@staticmethod
|
| 488 |
+
def has_word_merge(text: str, max_word_len: int = 8) -> bool:
|
| 489 |
+
words = text.split()
|
| 490 |
+
if any(len(w) > max_word_len for w in words):
|
| 491 |
+
return True
|
| 492 |
+
if len(words) == 1 and len(text) > 6:
|
| 493 |
+
return True
|
| 494 |
+
return False
|
| 495 |
+
|
| 496 |
+
@staticmethod
|
| 497 |
+
def classify(text: str) -> ErrorType:
|
| 498 |
+
has_rep = ErrorClassifier.has_char_repetition(text)
|
| 499 |
+
has_merge = ErrorClassifier.has_word_merge(text)
|
| 500 |
+
has_sub = ErrorClassifier.has_char_substitution(text)
|
| 501 |
+
error_count = sum([has_rep, has_merge, has_sub])
|
| 502 |
+
if error_count >= 2:
|
| 503 |
+
return ErrorType.MIXED
|
| 504 |
+
elif has_sub:
|
| 505 |
+
return ErrorType.CHAR_SUBSTITUTION
|
| 506 |
+
elif has_rep:
|
| 507 |
+
return ErrorType.CHAR_REPETITION
|
| 508 |
+
elif has_merge:
|
| 509 |
+
return ErrorType.WORD_MERGE
|
| 510 |
+
else:
|
| 511 |
+
return ErrorType.CLEAN
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 515 |
+
# RULES-BASED CORRECTOR
|
| 516 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 517 |
+
|
| 518 |
+
class RulesBasedCorrector:
|
| 519 |
+
"""Rules-based correction with keyboard proximity mapping."""
|
| 520 |
+
|
| 521 |
+
SUBSTITUTION_MAP = {
|
| 522 |
+
'ک': 'ك', 'ی': 'ي', 'ے': 'ي',
|
| 523 |
+
'پ': 'ب', 'چ': 'ج', 'ژ': 'ز',
|
| 524 |
+
'گ': 'ك', 'ڤ': 'ف', 'ڵ': 'ل',
|
| 525 |
+
'ڕ': 'ر', 'ڎ': 'د', 'ڼ': 'ن',
|
| 526 |
+
'ټ': 'ت', 'ډ': 'د', 'ړ': 'ر',
|
| 527 |
+
'ۀ': 'ه', 'ۃ': 'ة', 'ھ': 'ه',
|
| 528 |
+
'ە': 'ه', 'ڑ': 'ر'
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
PREPOSITIONS = {
|
| 532 |
+
'من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى',
|
| 533 |
+
'حتى', 'منذ', 'خلال', 'بعد', 'قبل',
|
| 534 |
+
'ب', 'ل', 'ك', 'لل'
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
KEYBOARD_NEIGHBORS = {
|
| 538 |
+
'ض': ['ص', 'ق'], 'ص': ['ض', 'ث', 'ق'], 'ث': ['ص', 'ق'],
|
| 539 |
+
'ق': ['ض', 'ص', 'ث', 'ف', 'غ'], 'ف': ['ق', 'غ', 'ع', 'ب'],
|
| 540 |
+
'غ': ['ق', 'ف', 'ع', 'ه'], 'ع': ['ف', 'غ', 'ه', 'خ'],
|
| 541 |
+
'ه': ['غ', 'ع', 'خ', 'ح'], 'خ': ['ع', 'ه', 'ح', 'ج'],
|
| 542 |
+
'ح': ['ه', 'خ', 'ج'], 'ج': ['خ', 'ح', 'د'],
|
| 543 |
+
'د': ['ج', 'ذ'], 'ذ': ['د'],
|
| 544 |
+
'ش': ['س', 'ي', 'ئ'], 'س': ['ش', 'ي', 'ب'],
|
| 545 |
+
'ي': ['ش', 'س', 'ب', 'ت'], 'ب': ['ي', 'س', 'ف', 'ل', 'ن'],
|
| 546 |
+
'ل': ['ب', 'ا', 'ن', 'م'], 'ا': ['ل', 'ت', 'م'],
|
| 547 |
+
'ت': ['ي', 'ا', 'ن'], 'ن': ['ب', 'ل', 'ت', 'م', 'ك'],
|
| 548 |
+
'م': ['ل', 'ا', 'ن', 'ك'], 'ك': ['ن', 'م', 'ط'],
|
| 549 |
+
'ط': ['ك', 'ظ'], 'ظ': ['ط'],
|
| 550 |
+
'ئ': ['ش', 'ء', 'ر'], 'ء': ['ئ', 'ؤ'], 'ؤ': ['ء', 'ر'],
|
| 551 |
+
'ر': ['ئ', 'ؤ', 'لا', 'ى', 'ز'], 'لا': ['ر', 'ى'],
|
| 552 |
+
'ى': ['ر', 'لا', 'ة', 'ز'], 'ة': ['ى', 'و', 'ز'],
|
| 553 |
+
'و': ['ة', 'ز'], 'ز': ['ر', 'ى', 'ة', 'و'],
|
| 554 |
+
'أ': ['ا', 'إ', 'آ'], 'إ': ['ا', 'أ'], 'آ': ['ا', 'أ'],
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
@staticmethod
|
| 558 |
+
def is_keyboard_neighbor(char1: str, char2: str) -> bool:
|
| 559 |
+
neighbors = RulesBasedCorrector.KEYBOARD_NEIGHBORS.get(char1, [])
|
| 560 |
+
return char2 in neighbors
|
| 561 |
+
|
| 562 |
+
@staticmethod
|
| 563 |
+
def fix_char_substitution(text: str) -> str:
|
| 564 |
+
for old, new in RulesBasedCorrector.SUBSTITUTION_MAP.items():
|
| 565 |
+
text = text.replace(old, new)
|
| 566 |
+
return text
|
| 567 |
+
|
| 568 |
+
@staticmethod
|
| 569 |
+
def fix_char_repetition(text: str) -> str:
|
| 570 |
+
text = re.sub(r'([^\d\s])\1{2,}', r'\1', text)
|
| 571 |
+
return text
|
| 572 |
+
|
| 573 |
+
@staticmethod
|
| 574 |
+
def advanced_heuristic_repair(text: str) -> str:
|
| 575 |
+
text = RulesBasedCorrector.fix_char_substitution(text)
|
| 576 |
+
text = RulesBasedCorrector.fix_char_repetition(text)
|
| 577 |
+
words = text.split()
|
| 578 |
+
processed_words = []
|
| 579 |
+
for word in words:
|
| 580 |
+
processed_words.append(RulesBasedCorrector._recursive_split(word))
|
| 581 |
+
return ' '.join(processed_words)
|
| 582 |
+
|
| 583 |
+
@staticmethod
|
| 584 |
+
def _recursive_split(word: str) -> str:
|
| 585 |
+
if len(word) < 4:
|
| 586 |
+
return word
|
| 587 |
+
separables = sorted(['من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى', 'حتى', 'منذ', 'خلال', 'بعد', 'قبل'], key=len, reverse=True)
|
| 588 |
+
for sep in separables:
|
| 589 |
+
if word == sep:
|
| 590 |
+
return word
|
| 591 |
+
if word.startswith(sep):
|
| 592 |
+
remainder = word[len(sep):]
|
| 593 |
+
if len(remainder) >= 3:
|
| 594 |
+
return sep + " " + RulesBasedCorrector._recursive_split(remainder)
|
| 595 |
+
if word.startswith('يا') and len(word) > 4:
|
| 596 |
+
return 'يا ' + RulesBasedCorrector._recursive_split(word[2:])
|
| 597 |
+
return word
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 601 |
+
# OUTPUT VALIDATOR (Hallucination Prevention)
|
| 602 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 603 |
+
|
| 604 |
+
class OutputValidator:
|
| 605 |
+
"""Validate model outputs to prevent hallucinations"""
|
| 606 |
+
|
| 607 |
+
@staticmethod
|
| 608 |
+
def calculate_edit_distance(s1: str, s2: str) -> int:
|
| 609 |
+
return Levenshtein.distance(s1, s2)
|
| 610 |
+
|
| 611 |
+
@staticmethod
|
| 612 |
+
def check_character_preservation(original: str, corrected: str) -> Tuple[bool, str]:
|
| 613 |
+
chars_original = set(original)
|
| 614 |
+
chars_corrected = set(corrected)
|
| 615 |
+
if not chars_original:
|
| 616 |
+
return True, "valid"
|
| 617 |
+
intersection = chars_original & chars_corrected
|
| 618 |
+
union = chars_original | chars_corrected
|
| 619 |
+
jaccard = len(intersection) / len(union) if union else 0
|
| 620 |
+
if jaccard < 0.35:
|
| 621 |
+
return False, "low_character_similarity"
|
| 622 |
+
return True, "valid"
|
| 623 |
+
|
| 624 |
+
@staticmethod
|
| 625 |
+
def check_word_count(original: str, corrected: str) -> Tuple[bool, str]:
|
| 626 |
+
len_orig = len(original.split())
|
| 627 |
+
len_corr = len(corrected.split())
|
| 628 |
+
if len_orig == 1:
|
| 629 |
+
if len_corr <= 3:
|
| 630 |
+
return True, "valid"
|
| 631 |
+
if len(original) > 12 and len_corr <= 6:
|
| 632 |
+
return True, "valid"
|
| 633 |
+
ratio = len_corr / len_orig if len_orig > 0 else 0
|
| 634 |
+
if ratio > 2.0 or ratio < 0.5:
|
| 635 |
+
return False, "word_count_mismatch"
|
| 636 |
+
return True, "valid"
|
| 637 |
+
|
| 638 |
+
def validate(self, original: str, corrected: str, error_type: str) -> Tuple[bool, str]:
|
| 639 |
+
if not corrected or not corrected.strip():
|
| 640 |
+
return False, "empty_output"
|
| 641 |
+
original_no_space = original.replace(' ', '').replace('\u200c', '')
|
| 642 |
+
corrected_no_space = corrected.replace(' ', '').replace('\u200c', '')
|
| 643 |
+
if original_no_space == corrected_no_space:
|
| 644 |
+
return True, "space_leniency_accept"
|
| 645 |
+
len_orig = len(original)
|
| 646 |
+
len_corr = len(corrected)
|
| 647 |
+
if len_corr > len_orig * 2.5:
|
| 648 |
+
return False, "too_long"
|
| 649 |
+
if len_corr < len_orig * 0.5:
|
| 650 |
+
if error_type == ErrorType.CHAR_REPETITION:
|
| 651 |
+
pass
|
| 652 |
+
else:
|
| 653 |
+
return False, "too_short"
|
| 654 |
+
is_valid_count, reason = self.check_word_count(original, corrected)
|
| 655 |
+
if not is_valid_count:
|
| 656 |
+
return False, reason
|
| 657 |
+
is_valid_chars, reason = self.check_character_preservation(original, corrected)
|
| 658 |
+
if not is_valid_chars:
|
| 659 |
+
return False, reason
|
| 660 |
+
return True, "valid"
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 664 |
+
# VOCABULARY MANAGER
|
| 665 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 666 |
+
|
| 667 |
+
class VocabularyManager:
|
| 668 |
+
"""Centralized vocabulary management for OOV/IV detection using CamelTools."""
|
| 669 |
+
|
| 670 |
+
def __init__(self, tokenizer):
|
| 671 |
+
self.tokenizer = tokenizer
|
| 672 |
+
from camel_tools.morphology.database import MorphologyDB
|
| 673 |
+
from camel_tools.morphology.analyzer import Analyzer
|
| 674 |
+
self._db = MorphologyDB.builtin_db()
|
| 675 |
+
self.analyzer = Analyzer(self._db)
|
| 676 |
+
logger.info("VocabularyManager initialized with CamelTools Analyzer")
|
| 677 |
+
|
| 678 |
+
def is_iv(self, word: str) -> bool:
|
| 679 |
+
clean = re.sub(r'[^\w]', '', word)
|
| 680 |
+
if not clean:
|
| 681 |
+
return True
|
| 682 |
+
return len(self.analyzer.analyze(clean)) > 0
|
| 683 |
+
|
| 684 |
+
def is_oov(self, word: str) -> bool:
|
| 685 |
+
return not self.is_iv(word)
|
| 686 |
+
|
| 687 |
+
def get_frequency_rank(self, word: str) -> int:
|
| 688 |
+
clean = re.sub(r'[^\w]', '', word)
|
| 689 |
+
return self.vocab_rank.get(clean, 999999)
|
| 690 |
+
|
| 691 |
+
def all_words_iv(self, text: str) -> bool:
|
| 692 |
+
words = text.split()
|
| 693 |
+
return all(self.is_iv(w) for w in words)
|
| 694 |
+
|
| 695 |
+
def count_oov_words(self, text: str) -> int:
|
| 696 |
+
words = text.split()
|
| 697 |
+
return sum(1 for w in words if self.is_oov(w))
|
| 698 |
+
|
| 699 |
+
def get_oov_words(self, text: str) -> List[str]:
|
| 700 |
+
words = text.split()
|
| 701 |
+
return [w for w in words if self.is_oov(w)]
|
| 702 |
+
|
| 703 |
+
def words_are_equivalent(self, word1: str, word2: str) -> bool:
|
| 704 |
+
norm1 = self.normalize_for_comparison(word1)
|
| 705 |
+
norm2 = self.normalize_for_comparison(word2)
|
| 706 |
+
return norm1 == norm2
|
| 707 |
+
|
| 708 |
+
@staticmethod
|
| 709 |
+
def damerau_levenshtein_distance(s1: str, s2: str) -> int:
|
| 710 |
+
return jellyfish.damerau_levenshtein_distance(s1, s2)
|
| 711 |
+
|
| 712 |
+
def calculate_similarity(self, original: str, corrected: str) -> float:
|
| 713 |
+
dist = self.damerau_levenshtein_distance(original, corrected)
|
| 714 |
+
max_len = max(len(original), len(corrected), 1)
|
| 715 |
+
return 1.0 - (dist / max_len)
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 719 |
+
# WORD ALIGNER
|
| 720 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 721 |
+
|
| 722 |
+
class WordAligner:
|
| 723 |
+
"""Aligns input and output words to create hybrid corrections."""
|
| 724 |
+
|
| 725 |
+
def __init__(self, vocab_manager):
|
| 726 |
+
self.vocab = vocab_manager
|
| 727 |
+
|
| 728 |
+
def align_words(self, input_text: str, output_text: str) -> str:
|
| 729 |
+
input_words = input_text.split()
|
| 730 |
+
output_words = output_text.split()
|
| 731 |
+
if abs(len(input_words) - len(output_words)) > 2:
|
| 732 |
+
input_oov = self.vocab.count_oov_words(input_text)
|
| 733 |
+
output_oov = self.vocab.count_oov_words(output_text)
|
| 734 |
+
return output_text if output_oov < input_oov else input_text
|
| 735 |
+
result = []
|
| 736 |
+
min_len = min(len(input_words), len(output_words))
|
| 737 |
+
for i in range(min_len):
|
| 738 |
+
in_word = input_words[i]
|
| 739 |
+
out_word = output_words[i]
|
| 740 |
+
best_word = self._select_best_word(in_word, out_word)
|
| 741 |
+
result.append(best_word)
|
| 742 |
+
if len(output_words) > min_len:
|
| 743 |
+
result.extend(output_words[min_len:])
|
| 744 |
+
elif len(input_words) > min_len:
|
| 745 |
+
for w in input_words[min_len:]:
|
| 746 |
+
if self.vocab.is_iv(w):
|
| 747 |
+
result.append(w)
|
| 748 |
+
return ' '.join(result)
|
| 749 |
+
|
| 750 |
+
def _select_best_word(self, input_word: str, output_word: str) -> str:
|
| 751 |
+
if input_word == output_word:
|
| 752 |
+
return input_word
|
| 753 |
+
in_iv = self.vocab.is_iv(input_word)
|
| 754 |
+
out_iv = self.vocab.is_iv(output_word)
|
| 755 |
+
if not in_iv and out_iv:
|
| 756 |
+
return output_word
|
| 757 |
+
if in_iv and not out_iv:
|
| 758 |
+
return input_word
|
| 759 |
+
if in_iv and out_iv:
|
| 760 |
+
# Bug 2.2: Do not prefer ة over ه if both are IV, because ه is often a valid possessive pronoun.
|
| 761 |
+
return input_word
|
| 762 |
+
if len(input_word) == len(output_word) and len(input_word) >= 3:
|
| 763 |
+
for i in range(len(input_word)):
|
| 764 |
+
if input_word[i] != output_word[i]:
|
| 765 |
+
hybrid = input_word[:i] + output_word[i] + input_word[i+1:]
|
| 766 |
+
if self.vocab.is_iv(hybrid):
|
| 767 |
+
return hybrid
|
| 768 |
+
hybrid2 = output_word[:i] + input_word[i] + output_word[i+1:]
|
| 769 |
+
if self.vocab.is_iv(hybrid2):
|
| 770 |
+
return hybrid2
|
| 771 |
+
return output_word
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 775 |
+
# SPLIT/MERGE SPECIALIST
|
| 776 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 777 |
+
|
| 778 |
+
class SplitMergeSpecialist:
|
| 779 |
+
"""Handles word splitting and merging with vocabulary validation."""
|
| 780 |
+
|
| 781 |
+
SEPARABLE_PREFIXES = [
|
| 782 |
+
'من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى', 'حتى', 'منذ', 'خلال',
|
| 783 |
+
'بعد', 'قبل', 'بين', 'حول', 'تحت', 'فوق', 'أمام', 'وراء', 'دون',
|
| 784 |
+
'أن', 'لن', 'لم', 'قد', 'سوف', 'كي', 'إذا', 'لو', 'مثل', 'غير',
|
| 785 |
+
'يا',
|
| 786 |
+
]
|
| 787 |
+
|
| 788 |
+
PROTECTED_WORDS = {
|
| 789 |
+
'في', 'من', 'على', 'عن', 'مع', 'إلى', 'الى', 'ان', 'أن', 'لا', 'ما', 'هو', 'هي',
|
| 790 |
+
'لم', 'لن', 'قد', 'كل', 'كان', 'ذلك', 'هذا', 'هذه', 'التي', 'الذي', 'بين',
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
ATTACHED_PREFIXES = [
|
| 794 |
+
'وال', 'بال', 'فال', 'كال', 'لل',
|
| 795 |
+
'وب', 'وف', 'ول', 'وك', 'وم', 'ون',
|
| 796 |
+
'فب', 'فل', 'فك', 'فم',
|
| 797 |
+
]
|
| 798 |
+
|
| 799 |
+
PRONOUN_SUFFIXES = {'كم', 'هم', 'ها', 'هن', 'كن', 'نا', 'هما', 'كما', 'تم', 'تن'}
|
| 800 |
+
|
| 801 |
+
def __init__(self, vocab_manager):
|
| 802 |
+
self.vocab = vocab_manager
|
| 803 |
+
self.separable_prefixes = sorted(
|
| 804 |
+
self.SEPARABLE_PREFIXES, key=len, reverse=True
|
| 805 |
+
)
|
| 806 |
+
|
| 807 |
+
def split_word(self, word: str) -> str:
|
| 808 |
+
if len(word) < 5:
|
| 809 |
+
return word
|
| 810 |
+
if self.vocab.is_iv(word):
|
| 811 |
+
return word
|
| 812 |
+
if word in self.PROTECTED_WORDS:
|
| 813 |
+
return word
|
| 814 |
+
for prefix in self.ATTACHED_PREFIXES:
|
| 815 |
+
if word.startswith(prefix):
|
| 816 |
+
remainder = word[len(prefix):]
|
| 817 |
+
if self.vocab.is_iv(remainder):
|
| 818 |
+
return word
|
| 819 |
+
if prefix.endswith('ال') and self.vocab.is_iv(remainder):
|
| 820 |
+
return word
|
| 821 |
+
for prefix in self.separable_prefixes:
|
| 822 |
+
if word.startswith(prefix) and len(word) > len(prefix) + 2:
|
| 823 |
+
remainder = word[len(prefix):]
|
| 824 |
+
if self.vocab.is_iv(remainder):
|
| 825 |
+
return f"{prefix} {remainder}"
|
| 826 |
+
for i in range(3, len(word) - 2):
|
| 827 |
+
left = word[:i]
|
| 828 |
+
right = word[i:]
|
| 829 |
+
if self.vocab.is_iv(left) and self.vocab.is_iv(right):
|
| 830 |
+
return f"{left} {right}"
|
| 831 |
+
return word
|
| 832 |
+
|
| 833 |
+
def merge_fragments(self, text: str) -> str:
|
| 834 |
+
words = text.split()
|
| 835 |
+
if len(words) < 2:
|
| 836 |
+
return text
|
| 837 |
+
result = []
|
| 838 |
+
i = 0
|
| 839 |
+
while i < len(words):
|
| 840 |
+
word = words[i]
|
| 841 |
+
if i + 1 < len(words):
|
| 842 |
+
next_word = words[i + 1]
|
| 843 |
+
merged = word + next_word
|
| 844 |
+
if len(next_word) == 1 and next_word in 'ةهاي':
|
| 845 |
+
if self.vocab.is_iv(merged):
|
| 846 |
+
result.append(merged)
|
| 847 |
+
i += 2
|
| 848 |
+
continue
|
| 849 |
+
if word == 'ال' and len(next_word) >= 2:
|
| 850 |
+
if self.vocab.is_iv(merged):
|
| 851 |
+
result.append(merged)
|
| 852 |
+
i += 2
|
| 853 |
+
continue
|
| 854 |
+
if self.vocab.is_oov(word) and self.vocab.is_oov(next_word):
|
| 855 |
+
if self.vocab.is_iv(merged):
|
| 856 |
+
result.append(merged)
|
| 857 |
+
i += 2
|
| 858 |
+
continue
|
| 859 |
+
if len(word) <= 2 and self.vocab.is_oov(word):
|
| 860 |
+
if self.vocab.is_iv(merged):
|
| 861 |
+
result.append(merged)
|
| 862 |
+
i += 2
|
| 863 |
+
continue
|
| 864 |
+
if next_word in self.PRONOUN_SUFFIXES:
|
| 865 |
+
if self.vocab.is_iv(merged) and not self.vocab.is_iv(word):
|
| 866 |
+
result.append(merged)
|
| 867 |
+
i += 2
|
| 868 |
+
continue
|
| 869 |
+
if len(word) <= 3 and len(next_word) <= 3:
|
| 870 |
+
if len(merged) >= 5 and self.vocab.is_iv(merged):
|
| 871 |
+
result.append(merged)
|
| 872 |
+
i += 2
|
| 873 |
+
continue
|
| 874 |
+
result.append(word)
|
| 875 |
+
i += 1
|
| 876 |
+
return ' '.join(result)
|
| 877 |
+
|
| 878 |
+
def process_text(self, text: str) -> str:
|
| 879 |
+
text = self.merge_fragments(text)
|
| 880 |
+
words = text.split()
|
| 881 |
+
processed = []
|
| 882 |
+
for word in words:
|
| 883 |
+
if self.vocab.is_oov(word) and len(word) >= 4:
|
| 884 |
+
split_result = self.split_word(word)
|
| 885 |
+
processed.append(split_result)
|
| 886 |
+
else:
|
| 887 |
+
processed.append(word)
|
| 888 |
+
return ' '.join(processed)
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 892 |
+
# EDIT DISTANCE CORRECTOR
|
| 893 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 894 |
+
|
| 895 |
+
class EditDistanceCorrector:
|
| 896 |
+
"""Generates candidates based on Levenshtein distance."""
|
| 897 |
+
|
| 898 |
+
def __init__(self, tokenizer):
|
| 899 |
+
self.tokenizer = tokenizer
|
| 900 |
+
self.vocab = {
|
| 901 |
+
w for w in tokenizer.get_vocab().keys()
|
| 902 |
+
if w.isalpha() and not w.startswith('##') and len(w) > 1
|
| 903 |
+
}
|
| 904 |
+
self.vocab_rank = {w: i for w, i in tokenizer.get_vocab().items()}
|
| 905 |
+
|
| 906 |
+
def edits1(self, word):
|
| 907 |
+
letters = 'أابتثجحخدذرزسشصضطظعغفقكلمنهويءآىةئؤ'
|
| 908 |
+
splits = [(word[:i], word[i:]) for i in range(len(word) + 1)]
|
| 909 |
+
deletes = [L + R[1:] for L, R in splits if R]
|
| 910 |
+
transposes = [L + R[1] + R[0] + R[2:] for L, R in splits if len(R)>1]
|
| 911 |
+
replaces = [L + c + R[1:] for L, R in splits if R for c in letters]
|
| 912 |
+
inserts = [L + c + R for L, R in splits for c in letters]
|
| 913 |
+
return set(deletes + transposes + replaces + inserts)
|
| 914 |
+
|
| 915 |
+
def edits2(self, word):
|
| 916 |
+
return (e2 for e1 in self.edits1(word) for e2 in self.edits1(e1))
|
| 917 |
+
|
| 918 |
+
def known(self, words):
|
| 919 |
+
return set(w for w in words if w in self.vocab)
|
| 920 |
+
|
| 921 |
+
def generate_candidate(self, text: str) -> str:
|
| 922 |
+
words = text.split()
|
| 923 |
+
corrected_words = []
|
| 924 |
+
for word in words:
|
| 925 |
+
clean_word = re.sub(r'[^\w]', '', word)
|
| 926 |
+
if clean_word in self.vocab:
|
| 927 |
+
corrected_words.append(word)
|
| 928 |
+
continue
|
| 929 |
+
candidates = self.known(self.edits1(clean_word))
|
| 930 |
+
if not candidates:
|
| 931 |
+
if len(clean_word) < 7:
|
| 932 |
+
candidates = self.known(self.edits2(clean_word))
|
| 933 |
+
if candidates:
|
| 934 |
+
best_candidate = min(candidates, key=lambda w: self.vocab_rank.get(w, 999999))
|
| 935 |
+
corrected_words.append(best_candidate)
|
| 936 |
+
else:
|
| 937 |
+
corrected_words.append(word)
|
| 938 |
+
return ' '.join(corrected_words)
|
| 939 |
+
|
| 940 |
+
|
| 941 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 942 |
+
# CONTEXTUAL CORRECTOR (MLM-based) — Optional, disabled by default to save RAM
|
| 943 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 944 |
+
|
| 945 |
+
class ContextualCorrector:
|
| 946 |
+
"""MLM-based contextual correction for confusion pairs"""
|
| 947 |
+
|
| 948 |
+
CONFUSION_PAIRS = [
|
| 949 |
+
('ض', 'ظ'), ('ذ', 'ز'), ('ث', 'س'), ('ص', 'س'),
|
| 950 |
+
('ط', 'ت'), ('ق', 'ك'), ('ه', 'ة'), ('ا', 'ى'),
|
| 951 |
+
('ت', 'د'), ('د', 'ض'), ('ك', 'ق'), ('غ', 'ق'),
|
| 952 |
+
('ج', 'ش'), ('س', 'ز'), ('ف', 'ب'), ('و', 'و'),
|
| 953 |
+
('ؤ', 'و'), ('ئ', 'ي'), ('ء', 'أ'), ('إ', 'أ'),
|
| 954 |
+
]
|
| 955 |
+
|
| 956 |
+
def __init__(self, model_name: str = 'aubmindlab/bert-base-arabertv02', cache_size: int = 10000):
|
| 957 |
+
from transformers import AutoTokenizer, AutoModelForMaskedLM
|
| 958 |
+
|
| 959 |
+
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 960 |
+
self.model = AutoModelForMaskedLM.from_pretrained(model_name)
|
| 961 |
+
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 962 |
+
self.model = self.model.to(self.device)
|
| 963 |
+
self.model.eval()
|
| 964 |
+
self.confusion_map = self._build_confusion_map()
|
| 965 |
+
self.cache_hits = 0
|
| 966 |
+
self.cache_misses = 0
|
| 967 |
+
self._score_cache = {}
|
| 968 |
+
self.cache_size = cache_size
|
| 969 |
+
self.vocab = self.tokenizer.get_vocab()
|
| 970 |
+
|
| 971 |
+
def _build_confusion_map(self):
|
| 972 |
+
confusion_map = {}
|
| 973 |
+
for char1, char2 in self.CONFUSION_PAIRS:
|
| 974 |
+
if char1 not in confusion_map:
|
| 975 |
+
confusion_map[char1] = []
|
| 976 |
+
if char2 not in confusion_map:
|
| 977 |
+
confusion_map[char2] = []
|
| 978 |
+
confusion_map[char1].append(char2)
|
| 979 |
+
confusion_map[char2].append(char1)
|
| 980 |
+
return confusion_map
|
| 981 |
+
|
| 982 |
+
def get_confusable_chars(self, char: str) -> List[str]:
|
| 983 |
+
return self.confusion_map.get(char, [])
|
| 984 |
+
|
| 985 |
+
def generate_candidates(self, word: str) -> List[str]:
|
| 986 |
+
candidates = [word]
|
| 987 |
+
for i, char in enumerate(word):
|
| 988 |
+
confusables = self.get_confusable_chars(char)
|
| 989 |
+
for conf_char in confusables:
|
| 990 |
+
candidate = word[:i] + conf_char + word[i+1:]
|
| 991 |
+
if candidate not in candidates:
|
| 992 |
+
candidates.append(candidate)
|
| 993 |
+
for i in range(len(word) - 1):
|
| 994 |
+
if word[i] == word[i+1]:
|
| 995 |
+
candidate = word[:i] + word[i+1:]
|
| 996 |
+
if candidate not in candidates:
|
| 997 |
+
candidates.append(candidate)
|
| 998 |
+
COMMON_CHARS = 'ابتثجحخدذرزسشصضطظعغفقكلمنهويأإآءئؤةى'
|
| 999 |
+
for i in range(len(word) + 1):
|
| 1000 |
+
for char in COMMON_CHARS:
|
| 1001 |
+
candidate = word[:i] + char + word[i:]
|
| 1002 |
+
if candidate in self.vocab and candidate not in candidates:
|
| 1003 |
+
candidates.append(candidate)
|
| 1004 |
+
if len(word) < 7:
|
| 1005 |
+
for i in range(len(word)):
|
| 1006 |
+
for char in COMMON_CHARS:
|
| 1007 |
+
if char != word[i]:
|
| 1008 |
+
candidate = word[:i] + char + word[i+1:]
|
| 1009 |
+
if candidate in self.vocab and candidate not in candidates:
|
| 1010 |
+
candidates.append(candidate)
|
| 1011 |
+
for i in range(len(word)):
|
| 1012 |
+
candidate = word[:i] + word[i+1:]
|
| 1013 |
+
if len(candidate) > 1:
|
| 1014 |
+
if candidate in self.vocab and candidate not in candidates:
|
| 1015 |
+
candidates.append(candidate)
|
| 1016 |
+
return candidates
|
| 1017 |
+
|
| 1018 |
+
def score_with_mlm(self, text: str, position: int, word: str) -> float:
|
| 1019 |
+
cache_key = f"{text}|{position}|{word}"
|
| 1020 |
+
if cache_key in self._score_cache:
|
| 1021 |
+
self.cache_hits += 1
|
| 1022 |
+
return self._score_cache[cache_key]
|
| 1023 |
+
self.cache_misses += 1
|
| 1024 |
+
words = text.split()
|
| 1025 |
+
if position >= len(words):
|
| 1026 |
+
return 0.0
|
| 1027 |
+
masked_words = words.copy()
|
| 1028 |
+
masked_words[position] = '[MASK]'
|
| 1029 |
+
masked_text = ' '.join(masked_words)
|
| 1030 |
+
inputs = self.tokenizer(masked_text, return_tensors='pt', padding=True, truncation=True)
|
| 1031 |
+
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
| 1032 |
+
with torch.no_grad():
|
| 1033 |
+
outputs = self.model(**inputs)
|
| 1034 |
+
predictions = outputs.logits
|
| 1035 |
+
mask_token_index = (inputs['input_ids'] == self.tokenizer.mask_token_id).nonzero(as_tuple=True)[1]
|
| 1036 |
+
if len(mask_token_index) == 0:
|
| 1037 |
+
return 0.0
|
| 1038 |
+
mask_token_logits = predictions[0, mask_token_index[0], :]
|
| 1039 |
+
probs = torch.softmax(mask_token_logits, dim=0)
|
| 1040 |
+
word_tokens = self.tokenizer.encode(word, add_special_tokens=False)
|
| 1041 |
+
if not word_tokens:
|
| 1042 |
+
return 0.0
|
| 1043 |
+
word_token_id = word_tokens[0]
|
| 1044 |
+
score = probs[word_token_id].item()
|
| 1045 |
+
if len(self._score_cache) >= self.cache_size:
|
| 1046 |
+
self._score_cache.pop(next(iter(self._score_cache)))
|
| 1047 |
+
self._score_cache[cache_key] = score
|
| 1048 |
+
return score
|
| 1049 |
+
|
| 1050 |
+
def score_candidates_batch(self, text: str, position: int, candidates: List[str]) -> dict:
|
| 1051 |
+
scores = {}
|
| 1052 |
+
for candidate in candidates:
|
| 1053 |
+
scores[candidate] = self.score_with_mlm(text, position, candidate)
|
| 1054 |
+
return scores
|
| 1055 |
+
|
| 1056 |
+
def predict_masked_token(self, text: str, position: int, top_k: int = 5) -> List[Tuple[str, float]]:
|
| 1057 |
+
words = text.split()
|
| 1058 |
+
if position >= len(words):
|
| 1059 |
+
return []
|
| 1060 |
+
masked_words = words.copy()
|
| 1061 |
+
masked_words[position] = '[MASK]'
|
| 1062 |
+
masked_text = ' '.join(masked_words)
|
| 1063 |
+
inputs = self.tokenizer(masked_text, return_tensors='pt', padding=True, truncation=True).to(self.device)
|
| 1064 |
+
with torch.no_grad():
|
| 1065 |
+
outputs = self.model(**inputs)
|
| 1066 |
+
predictions = outputs.logits
|
| 1067 |
+
mask_token_index = (inputs['input_ids'] == self.tokenizer.mask_token_id).nonzero(as_tuple=True)[1]
|
| 1068 |
+
if len(mask_token_index) == 0:
|
| 1069 |
+
return []
|
| 1070 |
+
mask_token_logits = predictions[0, mask_token_index[0], :]
|
| 1071 |
+
probs = torch.softmax(mask_token_logits, dim=0)
|
| 1072 |
+
top_k_weights, top_k_indices = torch.topk(probs, top_k, sorted=True)
|
| 1073 |
+
results = []
|
| 1074 |
+
for i in range(top_k):
|
| 1075 |
+
token_id = top_k_indices[i].item()
|
| 1076 |
+
score = top_k_weights[i].item()
|
| 1077 |
+
token = self.tokenizer.decode([token_id]).strip()
|
| 1078 |
+
if not token.startswith("##") and token not in self.tokenizer.all_special_tokens:
|
| 1079 |
+
results.append((token, score))
|
| 1080 |
+
return results
|
| 1081 |
+
|
| 1082 |
+
def refine_sentence_with_mask(self, text: str, threshold: float = 0.001, vocab_manager=None, raw_model_output=None) -> str:
|
| 1083 |
+
words = text.split()
|
| 1084 |
+
refined_words = words.copy()
|
| 1085 |
+
raw_words = raw_model_output.split() if raw_model_output else []
|
| 1086 |
+
for i, word in enumerate(words):
|
| 1087 |
+
if vocab_manager and vocab_manager.is_iv(word):
|
| 1088 |
+
continue
|
| 1089 |
+
if i < len(raw_words) and word == raw_words[i]:
|
| 1090 |
+
continue
|
| 1091 |
+
if len(word) <= 2:
|
| 1092 |
+
continue
|
| 1093 |
+
current_score = self.score_with_mlm(text, i, word)
|
| 1094 |
+
if current_score > threshold:
|
| 1095 |
+
continue
|
| 1096 |
+
predictions = self.predict_masked_token(text, i, top_k=10)
|
| 1097 |
+
for pred_word, pred_score in predictions:
|
| 1098 |
+
if pred_word == word:
|
| 1099 |
+
continue
|
| 1100 |
+
if abs(len(pred_word) - len(word)) > 1:
|
| 1101 |
+
continue
|
| 1102 |
+
dist = Levenshtein.distance(word, pred_word)
|
| 1103 |
+
max_len = max(len(word), len(pred_word))
|
| 1104 |
+
similarity = 1.0 - (dist / max_len)
|
| 1105 |
+
if similarity < 0.90:
|
| 1106 |
+
continue
|
| 1107 |
+
if vocab_manager and vocab_manager.is_oov(pred_word):
|
| 1108 |
+
continue
|
| 1109 |
+
if pred_score < 0.12:
|
| 1110 |
+
continue
|
| 1111 |
+
is_original_common = current_score > 0.001
|
| 1112 |
+
if is_original_common:
|
| 1113 |
+
if pred_score > current_score * 1000:
|
| 1114 |
+
refined_words[i] = pred_word
|
| 1115 |
+
break
|
| 1116 |
+
else:
|
| 1117 |
+
if pred_score > current_score * 50 and pred_score > 0.2:
|
| 1118 |
+
refined_words[i] = pred_word
|
| 1119 |
+
break
|
| 1120 |
+
return ' '.join(refined_words)
|
| 1121 |
+
|
| 1122 |
+
def calculate_sentence_score(self, text: str) -> float:
|
| 1123 |
+
words = text.split()
|
| 1124 |
+
if not words:
|
| 1125 |
+
return 0.0
|
| 1126 |
+
total_score = 0.0
|
| 1127 |
+
scored_words = 0
|
| 1128 |
+
for i, word in enumerate(words):
|
| 1129 |
+
score = self.score_with_mlm(text, i, word)
|
| 1130 |
+
total_score += score
|
| 1131 |
+
scored_words += 1
|
| 1132 |
+
if scored_words == 0:
|
| 1133 |
+
return 0.0
|
| 1134 |
+
return total_score / scored_words
|
| 1135 |
+
|
| 1136 |
+
|
| 1137 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 1138 |
+
# MAIN SPELL CHECKER CLASS
|
| 1139 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 1140 |
+
|
| 1141 |
+
class ArabicSpellChecker:
|
| 1142 |
+
"""Main Arabic Spell Checker class"""
|
| 1143 |
+
|
| 1144 |
+
def __init__(self, model, tokenizer, device, use_contextual: bool = True):
|
| 1145 |
+
self.model = model
|
| 1146 |
+
self.tokenizer = tokenizer
|
| 1147 |
+
self.device = device
|
| 1148 |
+
|
| 1149 |
+
self.postprocessor = AraSpellPostProcessor()
|
| 1150 |
+
self.classifier = ErrorClassifier()
|
| 1151 |
+
self.rules = RulesBasedCorrector()
|
| 1152 |
+
self.validator = OutputValidator()
|
| 1153 |
+
self.vocab_manager = VocabularyManager(tokenizer)
|
| 1154 |
+
self.edit_corrector = EditDistanceCorrector(tokenizer)
|
| 1155 |
+
self.split_merge = SplitMergeSpecialist(self.vocab_manager)
|
| 1156 |
+
self.word_aligner = WordAligner(self.vocab_manager)
|
| 1157 |
+
|
| 1158 |
+
self.use_contextual = use_contextual
|
| 1159 |
+
if use_contextual:
|
| 1160 |
+
try:
|
| 1161 |
+
logger.info("=" * 60)
|
| 1162 |
+
logger.info("[MLM/CONTEXTUAL] Loading AraBERT MLM model...")
|
| 1163 |
+
self.contextual = ContextualCorrector()
|
| 1164 |
+
logger.info("[MLM/CONTEXTUAL] ✅ LOADED SUCCESSFULLY")
|
| 1165 |
+
logger.info(f"[MLM/CONTEXTUAL] Device: {self.contextual.device}")
|
| 1166 |
+
logger.info(f"[MLM/CONTEXTUAL] Vocab size: {len(self.contextual.vocab)}")
|
| 1167 |
+
logger.info("=" * 60)
|
| 1168 |
+
except Exception as e:
|
| 1169 |
+
logger.warning("=" * 60)
|
| 1170 |
+
logger.warning(f"[MLM/CONTEXTUAL] ❌ FAILED TO LOAD: {e}")
|
| 1171 |
+
logger.warning("[MLM/CONTEXTUAL] Spelling will work without contextual validation")
|
| 1172 |
+
logger.warning("=" * 60)
|
| 1173 |
+
self.contextual = None
|
| 1174 |
+
self.use_contextual = False
|
| 1175 |
+
else:
|
| 1176 |
+
self.contextual = None
|
| 1177 |
+
logger.info("[MLM/CONTEXTUAL] Disabled by configuration (use_contextual=False)")
|
| 1178 |
+
|
| 1179 |
+
def _fix_repeated_end_chars(self, text: str) -> str:
|
| 1180 |
+
text = re.sub(r'([ا-ي])\1+\b', r'\1', text)
|
| 1181 |
+
return text
|
| 1182 |
+
|
| 1183 |
+
def _fix_merged_with_errors(self, text: str) -> str:
|
| 1184 |
+
# Bug 2.10: This regex was r'ال\2', deleting all instances of the character
|
| 1185 |
+
text = re.sub(r'ال([ا-ي])\1+([ا-ي]{2,})', r'ال\1\2', text)
|
| 1186 |
+
text = re.sub(r'\b([ا-ي]{3,})([ا-ي])\2+\b', r'\1\2', text)
|
| 1187 |
+
return text
|
| 1188 |
+
|
| 1189 |
+
def _split_merged_words_linguistic(self, text: str) -> str:
|
| 1190 |
+
# Bug 2.7: Catastrophic preposition splitting (e.g. منطق -> من طق)
|
| 1191 |
+
# Disabled generic regex splitting as it is highly destructive to valid vocabulary.
|
| 1192 |
+
return text
|
| 1193 |
+
|
| 1194 |
+
def _split_long_words_heuristic(self, text: str, max_length: int = 15) -> str:
|
| 1195 |
+
# Bug 2.8: Overzealous long word splitting (e.g. فيتامينات -> في تامينات)
|
| 1196 |
+
# Disabled as it creates more errors than it fixes.
|
| 1197 |
+
return text
|
| 1198 |
+
|
| 1199 |
+
def _normalize_tanween_patterns(self, text: str) -> str:
|
| 1200 |
+
# Bug 2.6: Blind replacement of trailing أ with اً corrupts verbs and nominative cases (قرأ -> قراً)
|
| 1201 |
+
text = re.sub(r'\s+أ\s+', ' ', text)
|
| 1202 |
+
text = re.sub(r'\b([بلك])\s+([ا-ي])', r'\1\2', text)
|
| 1203 |
+
return text
|
| 1204 |
+
|
| 1205 |
+
def preprocess(self, text: str) -> str:
|
| 1206 |
+
"""Preprocessing pipeline"""
|
| 1207 |
+
text = self.postprocessor.remove_harakat(text)
|
| 1208 |
+
text = self.postprocessor.remove_tatweel(text)
|
| 1209 |
+
text = self.postprocessor.normalize_special_chars(text)
|
| 1210 |
+
text = self._fix_repeated_end_chars(text)
|
| 1211 |
+
text = self._fix_merged_with_errors(text)
|
| 1212 |
+
text = self._split_merged_words_linguistic(text)
|
| 1213 |
+
text = self._split_long_words_heuristic(text)
|
| 1214 |
+
text = self._normalize_tanween_patterns(text)
|
| 1215 |
+
text = self.postprocessor.merge_separated_al(text)
|
| 1216 |
+
text = self.postprocessor.unified_collapse_repeated(text)
|
| 1217 |
+
text = self.rules.fix_char_substitution(text)
|
| 1218 |
+
text = self.rules.fix_char_repetition(text)
|
| 1219 |
+
text = self.postprocessor.normalize_spaces(text)
|
| 1220 |
+
return text
|
| 1221 |
+
|
| 1222 |
+
def postprocess(self, text: str, original: str = "") -> str:
|
| 1223 |
+
"""Postprocessing pipeline"""
|
| 1224 |
+
return self.postprocessor.full_postprocess(text, original, vocab_manager=self.vocab_manager)
|
| 1225 |
+
|
| 1226 |
+
def model_inference(self, text: str, num_return_sequences: int = 5) -> List[str]:
|
| 1227 |
+
"""Run seq2seq model inference and return top candidates."""
|
| 1228 |
+
inputs = self.tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=128)
|
| 1229 |
+
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
| 1230 |
+
with torch.no_grad():
|
| 1231 |
+
outputs = self.model.generate(
|
| 1232 |
+
**inputs,
|
| 1233 |
+
num_beams=5,
|
| 1234 |
+
num_return_sequences=num_return_sequences,
|
| 1235 |
+
early_stopping=True,
|
| 1236 |
+
return_dict_in_generate=True,
|
| 1237 |
+
output_scores=True
|
| 1238 |
+
)
|
| 1239 |
+
candidates = self.tokenizer.batch_decode(outputs.sequences, skip_special_tokens=True)
|
| 1240 |
+
self._last_beam_scores = {}
|
| 1241 |
+
if hasattr(outputs, 'sequences_scores') and outputs.sequences_scores is not None:
|
| 1242 |
+
scores = outputs.sequences_scores.tolist()
|
| 1243 |
+
for cand, score in zip(candidates, scores):
|
| 1244 |
+
self._last_beam_scores[cand] = score
|
| 1245 |
+
return candidates
|
| 1246 |
+
|
| 1247 |
+
def correct(self, text: str) -> str:
|
| 1248 |
+
"""
|
| 1249 |
+
Main correction pipeline (RERANKING APPROACH)
|
| 1250 |
+
|
| 1251 |
+
Steps:
|
| 1252 |
+
1. Preprocess
|
| 1253 |
+
2. Generate Candidates (Model Beams + Baseline)
|
| 1254 |
+
3. Rerank Candidates (Validator + Fluency)
|
| 1255 |
+
4. Select Best
|
| 1256 |
+
5. Postprocess
|
| 1257 |
+
"""
|
| 1258 |
+
if not text or not text.strip():
|
| 1259 |
+
return text
|
| 1260 |
+
|
| 1261 |
+
original = text
|
| 1262 |
+
|
| 1263 |
+
# 1. Preprocess
|
| 1264 |
+
preprocessed_text = self.preprocess(text)
|
| 1265 |
+
|
| 1266 |
+
# 2. Classify error type
|
| 1267 |
+
error_type = self.classifier.classify(preprocessed_text)
|
| 1268 |
+
|
| 1269 |
+
# 3. Generate Candidates
|
| 1270 |
+
candidates = []
|
| 1271 |
+
candidates.append(preprocessed_text)
|
| 1272 |
+
|
| 1273 |
+
rules_candidate = self.rules.advanced_heuristic_repair(text)
|
| 1274 |
+
candidates.append(rules_candidate)
|
| 1275 |
+
|
| 1276 |
+
edit_candidate = self.edit_corrector.generate_candidate(text)
|
| 1277 |
+
if edit_candidate != text and edit_candidate != rules_candidate:
|
| 1278 |
+
candidates.append(edit_candidate)
|
| 1279 |
+
|
| 1280 |
+
raw_model_output = None
|
| 1281 |
+
try:
|
| 1282 |
+
model_candidates = self.model_inference(preprocessed_text, num_return_sequences=5)
|
| 1283 |
+
raw_model_output = model_candidates[0] if model_candidates else None
|
| 1284 |
+
candidates.extend(model_candidates)
|
| 1285 |
+
|
| 1286 |
+
if model_candidates:
|
| 1287 |
+
hybrid_candidate = self.word_aligner.align_words(preprocessed_text, model_candidates[0])
|
| 1288 |
+
if hybrid_candidate not in candidates:
|
| 1289 |
+
candidates.append(hybrid_candidate)
|
| 1290 |
+
for beam in model_candidates[1:3]:
|
| 1291 |
+
hybrid_beam = self.word_aligner.align_words(preprocessed_text, beam)
|
| 1292 |
+
if hybrid_beam not in candidates:
|
| 1293 |
+
candidates.append(hybrid_beam)
|
| 1294 |
+
|
| 1295 |
+
if model_candidates and len(model_candidates) >= 3:
|
| 1296 |
+
try:
|
| 1297 |
+
beam_word_lists = [c.split() for c in model_candidates]
|
| 1298 |
+
max_words = max(len(wl) for wl in beam_word_lists)
|
| 1299 |
+
voted_words = []
|
| 1300 |
+
for pos in range(max_words):
|
| 1301 |
+
words_at_pos = []
|
| 1302 |
+
for wl in beam_word_lists:
|
| 1303 |
+
if pos < len(wl):
|
| 1304 |
+
words_at_pos.append(wl[pos])
|
| 1305 |
+
if words_at_pos:
|
| 1306 |
+
most_common = Counter(words_at_pos).most_common(1)[0][0]
|
| 1307 |
+
voted_words.append(most_common)
|
| 1308 |
+
voted_candidate = ' '.join(voted_words)
|
| 1309 |
+
if voted_candidate not in candidates:
|
| 1310 |
+
candidates.append(voted_candidate)
|
| 1311 |
+
except Exception:
|
| 1312 |
+
pass
|
| 1313 |
+
except Exception as e:
|
| 1314 |
+
logger.warning(f"Model inference failed: {e}")
|
| 1315 |
+
|
| 1316 |
+
# Remove duplicates
|
| 1317 |
+
unique_candidates = []
|
| 1318 |
+
seen = set()
|
| 1319 |
+
for c in candidates:
|
| 1320 |
+
if c not in seen:
|
| 1321 |
+
unique_candidates.append(c)
|
| 1322 |
+
seen.add(c)
|
| 1323 |
+
candidates = unique_candidates
|
| 1324 |
+
|
| 1325 |
+
# 4. Rerank Candidates
|
| 1326 |
+
best_candidate = preprocessed_text
|
| 1327 |
+
best_score = -1.0
|
| 1328 |
+
candidate_scores = []
|
| 1329 |
+
|
| 1330 |
+
for cand in candidates:
|
| 1331 |
+
is_valid, reason = self.validator.validate(original, cand, error_type.value)
|
| 1332 |
+
if len(cand) < len(original) * 0.5:
|
| 1333 |
+
is_valid = False
|
| 1334 |
+
reason = "too_short"
|
| 1335 |
+
|
| 1336 |
+
input_oov_count = self.vocab_manager.count_oov_words(original)
|
| 1337 |
+
cand_oov_count = self.vocab_manager.count_oov_words(cand)
|
| 1338 |
+
vocab_boost = 1.0
|
| 1339 |
+
|
| 1340 |
+
if input_oov_count > 0 and cand_oov_count < input_oov_count:
|
| 1341 |
+
oov_reduction = input_oov_count - cand_oov_count
|
| 1342 |
+
vocab_boost = 1.0 + (oov_reduction * 0.3)
|
| 1343 |
+
if cand_oov_count == 0 and self.vocab_manager.all_words_iv(cand):
|
| 1344 |
+
if not is_valid and reason not in ["empty_output"]:
|
| 1345 |
+
is_valid = True
|
| 1346 |
+
reason = "vocab_aware_accept"
|
| 1347 |
+
elif cand_oov_count > input_oov_count:
|
| 1348 |
+
vocab_boost = 0.5
|
| 1349 |
+
elif input_oov_count == 0 and cand_oov_count == 0:
|
| 1350 |
+
vocab_boost = 1.0
|
| 1351 |
+
|
| 1352 |
+
validity_factor = 1.0 if is_valid else 0.001
|
| 1353 |
+
|
| 1354 |
+
fluency_score = 0.0
|
| 1355 |
+
if self.use_contextual and self.contextual:
|
| 1356 |
+
try:
|
| 1357 |
+
fluency_score = self.contextual.calculate_sentence_score(cand)
|
| 1358 |
+
except Exception as e:
|
| 1359 |
+
logger.warning(f"Scoring failed: {e}")
|
| 1360 |
+
fluency_score = 0.5
|
| 1361 |
+
else:
|
| 1362 |
+
fluency_score = 1.0
|
| 1363 |
+
|
| 1364 |
+
dist = VocabularyManager.damerau_levenshtein_distance(preprocessed_text, cand)
|
| 1365 |
+
max_len = max(len(preprocessed_text), len(cand), 1)
|
| 1366 |
+
similarity = 1.0 - (dist / max_len)
|
| 1367 |
+
if cand == preprocessed_text:
|
| 1368 |
+
similarity = 1.0
|
| 1369 |
+
|
| 1370 |
+
keyboard_bonus = 1.0
|
| 1371 |
+
input_words = preprocessed_text.split()
|
| 1372 |
+
cand_words = cand.split()
|
| 1373 |
+
if len(input_words) == len(cand_words):
|
| 1374 |
+
for iw, cw in zip(input_words, cand_words):
|
| 1375 |
+
if iw != cw and len(iw) == len(cw):
|
| 1376 |
+
for ic, cc in zip(iw, cw):
|
| 1377 |
+
if ic != cc and RulesBasedCorrector.is_keyboard_neighbor(ic, cc):
|
| 1378 |
+
keyboard_bonus *= 1.05
|
| 1379 |
+
|
| 1380 |
+
if fluency_score > 0.85 and cand_oov_count == 0:
|
| 1381 |
+
if not is_valid and reason in ["too_short", "low_character_similarity", "word_count_mismatch"]:
|
| 1382 |
+
if len(cand) >= len(original) * 0.4:
|
| 1383 |
+
is_valid = True
|
| 1384 |
+
reason = "high_confidence_override"
|
| 1385 |
+
vocab_boost *= 1.2
|
| 1386 |
+
validity_factor = 1.0
|
| 1387 |
+
|
| 1388 |
+
fluency_exp = 0.3
|
| 1389 |
+
similarity_exp = 3.0
|
| 1390 |
+
beam_boost = 1.0
|
| 1391 |
+
if raw_model_output and cand == raw_model_output:
|
| 1392 |
+
beam_boost = 1.15
|
| 1393 |
+
|
| 1394 |
+
final_score = (fluency_score ** fluency_exp) * (similarity ** similarity_exp) * validity_factor * vocab_boost * keyboard_bonus * beam_boost
|
| 1395 |
+
|
| 1396 |
+
candidate_scores.append({
|
| 1397 |
+
'text': cand, 'is_valid': is_valid, 'reason': reason,
|
| 1398 |
+
'fluency': fluency_score, 'similarity': similarity,
|
| 1399 |
+
'vocab_boost': vocab_boost, 'input_oov': input_oov_count,
|
| 1400 |
+
'cand_oov': cand_oov_count, 'final_score': final_score
|
| 1401 |
+
})
|
| 1402 |
+
|
| 1403 |
+
if final_score > best_score:
|
| 1404 |
+
best_score = final_score
|
| 1405 |
+
best_candidate = cand
|
| 1406 |
+
|
| 1407 |
+
# Output Quality Scoring
|
| 1408 |
+
if best_candidate != preprocessed_text:
|
| 1409 |
+
preprocessed_score = 0.0
|
| 1410 |
+
for cs in candidate_scores:
|
| 1411 |
+
if cs['text'] == preprocessed_text:
|
| 1412 |
+
preprocessed_score = cs['final_score']
|
| 1413 |
+
break
|
| 1414 |
+
if preprocessed_score > 0 and best_score < preprocessed_score * 1.05:
|
| 1415 |
+
best_oov = self.vocab_manager.count_oov_words(best_candidate)
|
| 1416 |
+
prep_oov = self.vocab_manager.count_oov_words(preprocessed_text)
|
| 1417 |
+
if best_oov > prep_oov:
|
| 1418 |
+
best_candidate = preprocessed_text
|
| 1419 |
+
best_score = preprocessed_score
|
| 1420 |
+
|
| 1421 |
+
# Contextual Validation Layer
|
| 1422 |
+
if best_candidate != preprocessed_text and self.use_contextual and self.contextual:
|
| 1423 |
+
try:
|
| 1424 |
+
input_fluency = self.contextual.calculate_sentence_score(preprocessed_text)
|
| 1425 |
+
best_fluency = 0.0
|
| 1426 |
+
for cs in candidate_scores:
|
| 1427 |
+
if cs['text'] == best_candidate:
|
| 1428 |
+
best_fluency = cs['fluency']
|
| 1429 |
+
break
|
| 1430 |
+
if input_fluency > 0 and best_fluency > 0:
|
| 1431 |
+
if input_fluency > best_fluency * 1.5:
|
| 1432 |
+
input_oov = self.vocab_manager.count_oov_words(preprocessed_text)
|
| 1433 |
+
best_oov = self.vocab_manager.count_oov_words(best_candidate)
|
| 1434 |
+
if input_oov <= best_oov:
|
| 1435 |
+
best_candidate = preprocessed_text
|
| 1436 |
+
except Exception:
|
| 1437 |
+
pass
|
| 1438 |
+
|
| 1439 |
+
# 5. Postprocess Winner
|
| 1440 |
+
result = self.postprocess(best_candidate, original)
|
| 1441 |
+
|
| 1442 |
+
# IV-Safe Postprocessing Check
|
| 1443 |
+
if result != best_candidate:
|
| 1444 |
+
result_words = result.split()
|
| 1445 |
+
best_words = best_candidate.split()
|
| 1446 |
+
if len(result_words) == len(best_words):
|
| 1447 |
+
fixed_words = []
|
| 1448 |
+
for idx_fw, (rw, bw) in enumerate(zip(result_words, best_words)):
|
| 1449 |
+
if rw != bw:
|
| 1450 |
+
bw_iv = self.vocab_manager.is_iv(bw)
|
| 1451 |
+
rw_iv = self.vocab_manager.is_iv(rw)
|
| 1452 |
+
if bw_iv and not rw_iv:
|
| 1453 |
+
fixed_words.append(bw)
|
| 1454 |
+
else:
|
| 1455 |
+
fixed_words.append(rw)
|
| 1456 |
+
else:
|
| 1457 |
+
fixed_words.append(rw)
|
| 1458 |
+
result = ' '.join(fixed_words)
|
| 1459 |
+
|
| 1460 |
+
# 6. Contextual fine-tuning
|
| 1461 |
+
if self.use_contextual and self.contextual:
|
| 1462 |
+
if len(result) > 3:
|
| 1463 |
+
result = self.contextual.refine_sentence_with_mask(
|
| 1464 |
+
result, vocab_manager=self.vocab_manager,
|
| 1465 |
+
raw_model_output=raw_model_output
|
| 1466 |
+
)
|
| 1467 |
+
|
| 1468 |
+
# 7. Safe Split/Merge Post-processing
|
| 1469 |
+
result = self.split_merge.merge_fragments(result)
|
| 1470 |
+
|
| 1471 |
+
# 8. Output Stability Test
|
| 1472 |
+
if result != preprocessed_text and raw_model_output:
|
| 1473 |
+
try:
|
| 1474 |
+
re_preprocessed = self.preprocess(result)
|
| 1475 |
+
stability_dist = VocabularyManager.damerau_levenshtein_distance(result, re_preprocessed)
|
| 1476 |
+
result_len = max(len(result), 1)
|
| 1477 |
+
if stability_dist > 0:
|
| 1478 |
+
stability_ratio = stability_dist / result_len
|
| 1479 |
+
if stability_ratio > 0.15:
|
| 1480 |
+
raw_re = self.preprocess(raw_model_output)
|
| 1481 |
+
raw_stability = VocabularyManager.damerau_levenshtein_distance(
|
| 1482 |
+
raw_model_output, raw_re
|
| 1483 |
+
) / max(len(raw_model_output), 1)
|
| 1484 |
+
if raw_stability < stability_ratio:
|
| 1485 |
+
raw_oov = self.vocab_manager.count_oov_words(raw_model_output)
|
| 1486 |
+
our_oov = self.vocab_manager.count_oov_words(result)
|
| 1487 |
+
if raw_oov <= our_oov:
|
| 1488 |
+
result = raw_model_output
|
| 1489 |
+
except Exception:
|
| 1490 |
+
pass
|
| 1491 |
+
|
| 1492 |
+
# 9. Bidirectional Word-Level Validation
|
| 1493 |
+
if raw_model_output and result != raw_model_output:
|
| 1494 |
+
result_words = result.split()
|
| 1495 |
+
raw_words = raw_model_output.split()
|
| 1496 |
+
if len(result_words) == len(raw_words):
|
| 1497 |
+
corrected_words = []
|
| 1498 |
+
changed = False
|
| 1499 |
+
for rw, raw_w in zip(result_words, raw_words):
|
| 1500 |
+
if rw != raw_w:
|
| 1501 |
+
rw_iv = self.vocab_manager.is_iv(rw)
|
| 1502 |
+
raw_iv = self.vocab_manager.is_iv(raw_w)
|
| 1503 |
+
if not rw_iv and raw_iv:
|
| 1504 |
+
corrected_words.append(raw_w)
|
| 1505 |
+
changed = True
|
| 1506 |
+
elif rw_iv and raw_iv:
|
| 1507 |
+
input_words_list = preprocessed_text.split()
|
| 1508 |
+
idx = len(corrected_words)
|
| 1509 |
+
if idx < len(input_words_list):
|
| 1510 |
+
input_w = input_words_list[idx]
|
| 1511 |
+
rw_dist = Levenshtein.distance(input_w, rw)
|
| 1512 |
+
raw_dist = Levenshtein.distance(input_w, raw_w)
|
| 1513 |
+
if raw_dist < rw_dist:
|
| 1514 |
+
corrected_words.append(raw_w)
|
| 1515 |
+
changed = True
|
| 1516 |
+
else:
|
| 1517 |
+
corrected_words.append(rw)
|
| 1518 |
+
else:
|
| 1519 |
+
corrected_words.append(rw)
|
| 1520 |
+
else:
|
| 1521 |
+
corrected_words.append(rw)
|
| 1522 |
+
else:
|
| 1523 |
+
corrected_words.append(rw)
|
| 1524 |
+
if changed:
|
| 1525 |
+
new_result = ' '.join(corrected_words)
|
| 1526 |
+
new_oov = self.vocab_manager.count_oov_words(new_result)
|
| 1527 |
+
old_oov = self.vocab_manager.count_oov_words(result)
|
| 1528 |
+
if new_oov <= old_oov:
|
| 1529 |
+
result = new_result
|
| 1530 |
+
|
| 1531 |
+
# 10. SAFETY NET
|
| 1532 |
+
if raw_model_output and raw_model_output != result:
|
| 1533 |
+
raw_oov = self.vocab_manager.count_oov_words(raw_model_output)
|
| 1534 |
+
our_oov = self.vocab_manager.count_oov_words(result)
|
| 1535 |
+
if raw_oov == 0 and our_oov > 0:
|
| 1536 |
+
is_valid, reason = self.validator.validate(original, raw_model_output, "mixed")
|
| 1537 |
+
if is_valid or reason == "space_leniency_accept":
|
| 1538 |
+
result = raw_model_output
|
| 1539 |
+
elif raw_oov == 0 and our_oov == 0:
|
| 1540 |
+
raw_dist = VocabularyManager.damerau_levenshtein_distance(original, raw_model_output)
|
| 1541 |
+
our_dist = VocabularyManager.damerau_levenshtein_distance(original, result)
|
| 1542 |
+
result_vs_raw_dist = VocabularyManager.damerau_levenshtein_distance(result, raw_model_output)
|
| 1543 |
+
if raw_dist < our_dist and result_vs_raw_dist <= 3:
|
| 1544 |
+
raw_valid, _ = self.validator.validate(original, raw_model_output, "mixed")
|
| 1545 |
+
if raw_valid:
|
| 1546 |
+
result = raw_model_output
|
| 1547 |
+
elif raw_oov == 0:
|
| 1548 |
+
raw_wc = len(raw_model_output.split())
|
| 1549 |
+
our_wc = len(result.split())
|
| 1550 |
+
if raw_wc != our_wc:
|
| 1551 |
+
raw_dist = VocabularyManager.damerau_levenshtein_distance(original, raw_model_output)
|
| 1552 |
+
our_dist = VocabularyManager.damerau_levenshtein_distance(original, result)
|
| 1553 |
+
if raw_dist < our_dist:
|
| 1554 |
+
raw_valid, _ = self.validator.validate(original, raw_model_output, "mixed")
|
| 1555 |
+
if raw_valid:
|
| 1556 |
+
result = raw_model_output
|
| 1557 |
+
# ── FINAL PASS: Hamza whitelist + Ta Marbuta fixes (unrevertable) ──
|
| 1558 |
+
# These are applied AFTER all validation/safety steps so they can't
|
| 1559 |
+
# be undone by Steps 8-10 which compare against raw_model_output.
|
| 1560 |
+
# The root issue: Steps 8-10 use edit distance to INPUT (which has errors)
|
| 1561 |
+
# so they revert corrections back to the erroneous form.
|
| 1562 |
+
result = AraSpellPostProcessor.fix_common_hamza(result)
|
| 1563 |
+
result = AraSpellPostProcessor.fix_ha_ta_marbuta(result, vocab_manager=self.vocab_manager)
|
| 1564 |
+
|
| 1565 |
+
# 11. DESTRUCTIVE TOKENIZATION GUARD
|
| 1566 |
+
# Arabic orthography does not use standalone 1-letter words except prepositions.
|
| 1567 |
+
# If the model creates a standalone 1-letter word that was not in the original,
|
| 1568 |
+
# check if it's a legitimate prefix separation (e.g. بالشاروع→ب الشارع).
|
| 1569 |
+
orig_standalone = set(w for w in original.split() if len(w) == 1)
|
| 1570 |
+
orig_words = original.split()
|
| 1571 |
+
res_words_list = result.split()
|
| 1572 |
+
for idx, w in enumerate(res_words_list):
|
| 1573 |
+
if len(w) == 1 and w not in orig_standalone:
|
| 1574 |
+
if w in 'واتيبلفك':
|
| 1575 |
+
# Check if this is a legitimate prefix separation:
|
| 1576 |
+
# The original word should have started with this letter as a prefix
|
| 1577 |
+
is_prefix_separation = False
|
| 1578 |
+
if w in 'وفبلك' and idx + 1 < len(res_words_list):
|
| 1579 |
+
next_word = res_words_list[idx + 1]
|
| 1580 |
+
combined = w + next_word
|
| 1581 |
+
# If any original word started with the prefix letter and
|
| 1582 |
+
# the remainder matches the next word, it's legitimate
|
| 1583 |
+
for ow in orig_words:
|
| 1584 |
+
if ow.startswith(w) and len(ow) > 2:
|
| 1585 |
+
is_prefix_separation = True
|
| 1586 |
+
break
|
| 1587 |
+
|
| 1588 |
+
if not is_prefix_separation:
|
| 1589 |
+
logger.info(f"[SPELLING] Blocked destructive tokenization (hallucinated standalone '{w}'): '{original}' -> '{result}'")
|
| 1590 |
+
result = original
|
| 1591 |
+
break
|
| 1592 |
+
|
| 1593 |
+
# 12. MORPHOLOGICAL MUTATION GUARD (Verb -> Noun)
|
| 1594 |
+
# Prevents spelling from changing a plural verb (e.g. صممو) to a noun (e.g. مصممو) by prepending م
|
| 1595 |
+
if len(orig_words) == len(res_words_list):
|
| 1596 |
+
for idx in range(len(orig_words)):
|
| 1597 |
+
ow = orig_words[idx]
|
| 1598 |
+
rw = res_words_list[idx]
|
| 1599 |
+
# If the word didn't start with م but the correction does, and it looks like a plural verb
|
| 1600 |
+
if not ow.startswith('م') and rw.startswith('م') and rw[1:] == ow and ow.endswith('و'):
|
| 1601 |
+
logger.info(f"[SPELLING] Blocked morphological mutation (verb→noun '{ow}'→'{rw}')")
|
| 1602 |
+
res_words_list[idx] = ow
|
| 1603 |
+
result = ' '.join(res_words_list)
|
| 1604 |
+
|
| 1605 |
+
return result
|
| 1606 |
+
|
src/nlp/grammar/grammar_rules.py
CHANGED
|
@@ -718,16 +718,35 @@ class ArabicGrammarGuard:
|
|
| 718 |
"""Apply all grammar rules to model output."""
|
| 719 |
text = self.preserve_numbers(original_text, generated_text)
|
| 720 |
|
| 721 |
-
# ── Fix Hallucinated Subject Gender ──
|
| 722 |
-
# If model incorrectly changes female subject to male, restore it.
|
| 723 |
orig_words = original_text.split()
|
| 724 |
corr_words = text.split()
|
| 725 |
if len(orig_words) == len(corr_words):
|
| 726 |
-
for i
|
|
|
|
|
|
|
| 727 |
o_clean = o.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 728 |
c_clean = c.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
# If model dropped 'ة' from a word of length >= 4
|
| 730 |
-
|
| 731 |
corr_words[i] = o
|
| 732 |
text = " ".join(corr_words)
|
| 733 |
|
|
|
|
| 718 |
"""Apply all grammar rules to model output."""
|
| 719 |
text = self.preserve_numbers(original_text, generated_text)
|
| 720 |
|
| 721 |
+
# ── Fix Hallucinated Subject Gender & Protect Structured Data ──
|
|
|
|
| 722 |
orig_words = original_text.split()
|
| 723 |
corr_words = text.split()
|
| 724 |
if len(orig_words) == len(corr_words):
|
| 725 |
+
for i in range(len(orig_words)):
|
| 726 |
+
o = orig_words[i]
|
| 727 |
+
c = corr_words[i]
|
| 728 |
o_clean = o.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 729 |
c_clean = c.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 730 |
+
|
| 731 |
+
# Protect structured data (English, JSON, Hashtags, Code)
|
| 732 |
+
if re.search(r'[a-zA-Z]|\{.*\}|\[.*\]|<.*>|#\S+|@\S+', o):
|
| 733 |
+
corr_words[i] = o
|
| 734 |
+
# Revert grammar hallucinations on adjacent Arabic words caused by structured data
|
| 735 |
+
if i > 0:
|
| 736 |
+
prev_o = orig_words[i-1]
|
| 737 |
+
prev_c = corr_words[i-1]
|
| 738 |
+
clean_c = prev_c.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 739 |
+
if len(clean_c) <= len(prev_o) + 2 and clean_c.startswith(prev_o):
|
| 740 |
+
corr_words[i-1] = prev_o + prev_c[len(clean_c):]
|
| 741 |
+
if i < len(orig_words) - 1:
|
| 742 |
+
next_o = orig_words[i+1]
|
| 743 |
+
next_c = corr_words[i+1]
|
| 744 |
+
clean_c = next_c.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 745 |
+
if len(clean_c) <= len(next_o) + 2 and clean_c.startswith(next_o):
|
| 746 |
+
corr_words[i+1] = next_o + next_c[len(clean_c):]
|
| 747 |
+
|
| 748 |
# If model dropped 'ة' from a word of length >= 4
|
| 749 |
+
elif o_clean.endswith('ة') and not c_clean.endswith('ة') and o_clean[:-1] == c_clean:
|
| 750 |
corr_words[i] = o
|
| 751 |
text = " ".join(corr_words)
|
| 752 |
|
src/nlp/punctuation/punctuation_rules.py
CHANGED
|
@@ -165,6 +165,31 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 165 |
original = diff.get('original', '')
|
| 166 |
correction = diff.get('correction', '')
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
# ── Rule 0 (FIX-01 + FIX-30 + Merged Guard): Terminal punctuation ──
|
| 169 |
# PuncAra-v1 unconditionally adds . or ؟ to every sentence.
|
| 170 |
# This rule catches the pattern: "word" → "word." / "word؟" / "word،"
|
|
|
|
| 165 |
original = diff.get('original', '')
|
| 166 |
correction = diff.get('correction', '')
|
| 167 |
|
| 168 |
+
# ── Protect Structured Data (English, URLs, Emails, Hashtags, Code/JSON) ──
|
| 169 |
+
# Block punctuation modifications near structured data unless it's a valid terminal punctuation
|
| 170 |
+
if re.search(r'[a-zA-Z]|\{.*\}|\[.*\]|<.*>|#\S+|@\S+', original):
|
| 171 |
+
is_at_end = False
|
| 172 |
+
if full_text and 'end' in diff:
|
| 173 |
+
is_at_end = diff['end'] >= len(full_text) - 2
|
| 174 |
+
elif not full_text:
|
| 175 |
+
is_at_end = True
|
| 176 |
+
|
| 177 |
+
orig_punct = sum(1 for c in original if c in '.,،؛؟!:;?!')
|
| 178 |
+
corr_punct = sum(1 for c in correction if c in '.,،؛؟!:;?!')
|
| 179 |
+
|
| 180 |
+
# Block mid-sentence punctuation additions (e.g. adding comma after English word)
|
| 181 |
+
if corr_punct > orig_punct and not is_at_end:
|
| 182 |
+
logger.info(f"[PUNC-SAFETY] Blocked mid-sentence punctuation on structured data: '{original}' -> '{correction}'")
|
| 183 |
+
return False
|
| 184 |
+
|
| 185 |
+
# Block spacing corruptions in JSON/Code (e.g. {"name"} -> { "name" })
|
| 186 |
+
if re.search(r'\{.*\}|\[.*\]|<.*>|https?://', original):
|
| 187 |
+
# Only allow if the ONLY change is appending a terminal mark at the very end
|
| 188 |
+
if original != correction and not (is_at_end and correction.endswith(('.', '؟')) and correction[:-1].rstrip() == original.rstrip()):
|
| 189 |
+
logger.info(f"[PUNC-SAFETY] Blocked corruption of JSON/Code/URL: '{original}' -> '{correction}'")
|
| 190 |
+
return False
|
| 191 |
+
correction = diff.get('correction', '')
|
| 192 |
+
|
| 193 |
# ── Rule 0 (FIX-01 + FIX-30 + Merged Guard): Terminal punctuation ──
|
| 194 |
# PuncAra-v1 unconditionally adds . or ؟ to every sentence.
|
| 195 |
# This rule catches the pattern: "word" → "word." / "word؟" / "word،"
|
src/nlp/spelling/araspell_rules.py
CHANGED
|
@@ -630,6 +630,13 @@ class OutputValidator:
|
|
| 630 |
def validate(self, original: str, corrected: str, error_type: str) -> Tuple[bool, str]:
|
| 631 |
if not corrected or not corrected.strip():
|
| 632 |
return False, "empty_output"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
original_no_space = original.replace(' ', '').replace('\u200c', '')
|
| 634 |
corrected_no_space = corrected.replace(' ', '').replace('\u200c', '')
|
| 635 |
if original_no_space == corrected_no_space:
|
|
|
|
| 630 |
def validate(self, original: str, corrected: str, error_type: str) -> Tuple[bool, str]:
|
| 631 |
if not corrected or not corrected.strip():
|
| 632 |
return False, "empty_output"
|
| 633 |
+
|
| 634 |
+
# ── Protect Structured Data ──
|
| 635 |
+
# Reject spelling modifications to English, JSON, URLs, Emails, Hashtags
|
| 636 |
+
if re.search(r'[a-zA-Z]|\{.*\}|\[.*\]|<.*>|#\S+|@\S+', original):
|
| 637 |
+
if original != corrected:
|
| 638 |
+
return False, "structural_protection"
|
| 639 |
+
|
| 640 |
original_no_space = original.replace(' ', '').replace('\u200c', '')
|
| 641 |
corrected_no_space = corrected.replace(' ', '').replace('\u200c', '')
|
| 642 |
if original_no_space == corrected_no_space:
|
tests/phase10/reports/phase10_results.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|