youssefreda9 commited on
Commit
e7b2a51
·
1 Parent(s): 41ea30b

Fix structural regressions

Browse files
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
- font-size: 18px !important;
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: 1px !important;
557
- height: 20px !important;
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
- const icon = type === 'spelling' ? '✕' : type === 'grammar' ? '!' : '✓';
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/icon128.png')}" alt="بيان" style="width: 24px; height: 24px; object-fit: contain;" draggable="false" />
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="إغلاق"></button>
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, (o, c) in enumerate(zip(orig_words, corr_words)):
 
 
727
  o_clean = o.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
728
  c_clean = c.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
  # If model dropped 'ة' from a word of length >= 4
730
- if o_clean.endswith('ة') and not c_clean.endswith('ة') and o_clean[:-1] == c_clean:
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