youssefreda9 commited on
Commit
53f433b
·
1 Parent(s): ccf3eef

feat: Add AraSpell spelling correction endpoint (POST /api/spelling) with lazy model loading - Extract AraSpell pipeline into src/nlp/spelling/ (araspell_rules.py + araspell_service.py) - Pre-download AraBERT encoder-decoder + checkpoint in Dockerfile - Lazy load on first request, contextual=False to save RAM - Update health check to report spelling status

Browse files
src/ara_spell.py → AraSpell.py RENAMED
@@ -1,35 +1,54 @@
1
- # ## 📦 Part 1: Imports & Setup
2
-
3
 
4
  import re
 
 
5
  import torch
6
  import os
 
7
  from transformers import AutoTokenizer, EncoderDecoderModel
8
  import Levenshtein
9
- import jellyfish # NEW: For Damerau-Levenshtein (transpositions as 1 edit)
10
-
11
- print("✅ All imports successful")
12
- print(f"🔧 PyTorch version: {torch.__version__}")
13
- print(f"🔧 CUDA available: {torch.cuda.is_available()}")
14
- if torch.cuda.is_available():
15
- print(f"🔧 GPU: {torch.cuda.get_device_name(0)}")
16
 
 
 
17
 
18
  # ═══════════════════════════════════════════════════════════════════════════════
19
- # 🏆 PRODUCTION SPELL CHECKER - OPTIMIZED VERSION (Merged from src/)
20
  # ═══════════════════════════════════════════════════════════════════════════════
21
 
 
22
 
23
- # ═══════════════════════════════════════════════════════════════════════════════
24
- # 🏆 PRODUCTION SPELL CHECKER - OPTIMIZED VERSION (Merged from src/)
25
- # ═══════════════════════════════════════════════════════════════════════════════
26
- # IMPROVEMENTS:
27
- # 1. unified_collapse_repeated() - More conservative (3+ for Arabic)
28
- # 2. fix_hamza_conservative() - Only fixes word endings
29
- # 3. remove_hallucinations() - Removes duplicate words and trailing 'و'
30
- # 4. Expanded PREPOSITIONS - 16 prepositions instead of 2
31
- # 5. Better word splitting and joining
32
- # ═══════════════════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  from enum import Enum
35
  from typing import List, Tuple, Optional
@@ -47,11 +66,11 @@ class ErrorType(Enum):
47
  CLEAN = "clean"
48
 
49
  # ═══════════════════════════════════════════════════════════════════════════════
50
- # POST PROCESSOR (OPTIMIZED - Merged from src/)
51
  # ═══════════════════════════════════════════════════════════════════════════════
52
 
53
  class AraSpellPostProcessor:
54
- """Post-processing techniques (OPTIMIZED VERSION)"""
55
 
56
  ARABIC_HARAKAT = 'ًٌٍَُِّْ'
57
  TATWEEL = 'ـ'
@@ -60,9 +79,7 @@ class AraSpellPostProcessor:
60
  }
61
  ARABIC_CONSONANTS = set('بتثجحخدذرزسشصضطظعغفقكلمن')
62
 
63
- # ═══════════════════════════════════════════════════════════════════════════════
64
- # BASIC NORMALIZATION
65
- # ═══════════════════════════════════════════════════════════════════════════════
66
 
67
  @staticmethod
68
  def remove_harakat(text: str) -> str:
@@ -81,16 +98,13 @@ class AraSpellPostProcessor:
81
  text = text.replace(old, new)
82
  return text
83
 
84
- # ═══════════════════════════════════════════════════════════════════════════════
85
- # UNIFIED CORE FUNCTIONS (NEW from src/)
86
- # ═══════════════════════════════════════════════════════════════════════════════
87
 
88
  @staticmethod
89
  def unified_collapse_repeated(text: str) -> str:
90
  """
91
- UNIFIED repetition collapse (IMPROVED!)
92
- - Arabic: 3+ consecutive → 1 (more conservative!)
93
- - Latin: 2+ consecutive → 1
94
  """
95
  # Arabic characters: 3+ → 1
96
  text = re.sub(r"([\u0600-\u06FF])\1{2,}", r"\1", text)
@@ -100,14 +114,9 @@ class AraSpellPostProcessor:
100
 
101
  return text
102
 
103
-
104
-
105
  @staticmethod
106
  def remove_duplicate_words(text: str) -> str:
107
- """
108
- Remove consecutive duplicate words (NEW from src/)
109
- Examples: كتاب كتاب → كتاب
110
- """
111
  words = text.split()
112
  if len(words) < 2:
113
  return text
@@ -119,13 +128,9 @@ class AraSpellPostProcessor:
119
 
120
  return ' '.join(result)
121
 
122
-
123
-
124
  @staticmethod
125
  def normalize_spaces(text: str) -> str:
126
- """
127
- UNIFIED space normalization (NEW from src/)
128
- """
129
  # Multiple spaces → single
130
  text = re.sub(r' +', ' ', text)
131
 
@@ -159,20 +164,11 @@ class AraSpellPostProcessor:
159
  i += 1
160
  return ' '.join(result)
161
 
162
- # ═══════════════════════════════════════════════════════════════════════════════
163
- # HAMZA HANDLING (NEW from src/)
164
- # ═══════════════════════════════════════════════════════════════════════════════
165
 
166
  @staticmethod
167
  def fix_hamza_conservative(text: str) -> str:
168
- """
169
- CONSERVATIVE Hamza normalization (NEW!)
170
- Only normalizes at word END, not middle
171
-
172
- Examples:
173
- ✓ "المدرسه" → "المدرسة" (end of word)
174
- ✓ "سأل" → "سأل" (middle - KEEP IT!)
175
- """
176
  words = text.split()
177
  result = []
178
 
@@ -191,34 +187,63 @@ class AraSpellPostProcessor:
191
  return ' '.join(result)
192
 
193
  @staticmethod
194
- def fix_ha_ta_marbuta(text: str) -> str:
195
  """
196
- Fix ه → ة at end of words (pattern-based)
 
 
 
 
 
 
 
 
197
  """
 
 
 
198
  words = text.split()
199
  result = []
200
 
201
  for word in words:
 
 
 
 
 
202
  if len(word) >= 4 and word.endswith('ه'):
203
  # Check if second-to-last char is a consonant
204
  if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS:
205
- result.append(word[:-1] + 'ة')
206
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  result.append(word)
208
 
209
  return ' '.join(result)
210
 
211
- # ═══════════════════════════════════════════════════════════════════════════════
212
- # HALLUCINATION REMOVAL (NEW from src/)
213
- # ═══════════════════════════════════════════════════════════════════════════════
214
 
215
  @staticmethod
216
  def remove_hallucinations(text: str) -> str:
217
- """
218
- Remove model hallucinations (NEW from src/):
219
- - Duplicate words
220
- - Trailing 'و' artifacts
221
- """
222
  words = text.split()
223
  if not words:
224
  return text
@@ -270,9 +295,7 @@ class AraSpellPostProcessor:
270
 
271
  return text
272
 
273
- # ═══════════════════════════════════════════════════════════════════════════════
274
- # WORD SPLITTING & MERGING (NEW from src/)
275
- # ═══════════════════════════════════════════════════════════════════════════════
276
 
277
  @staticmethod
278
  def merge_separated_al(text: str) -> str:
@@ -281,12 +304,7 @@ class AraSpellPostProcessor:
281
 
282
  @staticmethod
283
  def join_fragments(text: str) -> str:
284
- """
285
- IMPROVED: Join short fragments with better validation (NEW from src/)
286
- الط + الب → الطالب
287
-
288
- FIXED: No longer joins valid separate words like "في من"
289
- """
290
  words = text.split()
291
  if len(words) < 2:
292
  return text
@@ -341,14 +359,13 @@ class AraSpellPostProcessor:
341
 
342
  return ' '.join(result)
343
 
344
- # ═══════════════════════════════════════════════════════════════════════════════
345
- # MAIN PIPELINES
346
- # ═══════════════════════════════════════════════════════════════════════════════
347
 
348
  @staticmethod
349
- def full_postprocess(text: str, original: str = "") -> str:
350
  """
351
  Apply all post-processing steps (OPTIMIZED ORDER!)
 
352
  """
353
  # 1. Remove hallucinated prefixes
354
  if original:
@@ -366,8 +383,8 @@ class AraSpellPostProcessor:
366
  # 5. Fix Hamza (CONSERVATIVE!)
367
  text = AraSpellPostProcessor.fix_hamza_conservative(text)
368
 
369
- # 6. Fix Ta Marbuta
370
- text = AraSpellPostProcessor.fix_ha_ta_marbuta(text)
371
 
372
  # 7. Remove word repetition with 'و'
373
  text = AraSpellPostProcessor.remove_word_repetition_with_wa(text)
@@ -382,7 +399,7 @@ class AraSpellPostProcessor:
382
 
383
 
384
  # ─────────────────────────────────────────────────────────────────────────────
385
- # ERROR CLASSIFIER (Keep from original notebook)
386
  # ─────────────────────────────────────────────────────────────────────────────
387
 
388
  class ErrorClassifier:
@@ -427,18 +444,12 @@ class ErrorClassifier:
427
  else:
428
  return ErrorType.CLEAN
429
 
430
-
431
  # ═══════════════════════════════════════════════════════════════════════════════
432
- # Part 3: Production Spell Checker Class
433
- # This is the best pipeline based on extensive testing of 8+ different approaches
434
- # ═══════════════════════════════════════════════════════════════════════════════
435
-
436
- # ═══════════════════════════════════════════════════════════════════════════════
437
- # RULES-BASED CORRECTOR (EXPANDED - Merged from src/)
438
  # ═══════════════════════════════════════════════════════════════════════════════
439
 
440
  class RulesBasedCorrector:
441
- """Rules-based correction (EXPANDED VERSION)"""
442
 
443
  # Persian/Urdu → Arabic mapping
444
  SUBSTITUTION_MAP = {
@@ -459,6 +470,55 @@ class RulesBasedCorrector:
459
  'لل'
460
  }
461
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  @staticmethod
463
  def fix_char_substitution(text: str) -> str:
464
  """Replace Persian/Urdu characters with Arabic"""
@@ -468,10 +528,7 @@ class RulesBasedCorrector:
468
 
469
  @staticmethod
470
  def fix_char_repetition(text: str) -> str:
471
- """
472
- Remove excessive character repetition (IMPROVED!)
473
- Now: 3+ consecutive → 1 (more conservative)
474
- """
475
  # Only collapse 3+ repetitions (not 2+)
476
  text = re.sub(r'([^\d\s])\1{2,}', r'\1', text)
477
  return text
@@ -596,11 +653,7 @@ class OutputValidator:
596
  if not corrected or not corrected.strip():
597
  return False, "empty_output"
598
 
599
- # ═══════════════════════════════════════════════════════════════════════════
600
- # SPACE LENIENCY (Phase 1 - Solutions.md الفكرة 3)
601
- # ═══════════════════════════════════════════════════════════════════════════
602
- # If the ONLY difference is whitespace → accept if resulting words are valid
603
- # This fixes split/merge issues like "في��لمدرسة" → "في المدرسة"
604
  original_no_space = original.replace(' ', '').replace('\u200c', '') # Also handle ZWNJ
605
  corrected_no_space = corrected.replace(' ', '').replace('\u200c', '')
606
 
@@ -639,9 +692,8 @@ class OutputValidator:
639
 
640
  return True, "valid"
641
 
642
-
643
  # ═══════════════════════════════════════════════════════════════════════════════
644
- # VOCABULARY MANAGER (NEW - Phase 1)
645
  # ═══════════════════════════════════════════════════════════════════════════════
646
 
647
  class VocabularyManager:
@@ -673,7 +725,7 @@ class VocabularyManager:
673
  # Build normalized vocabulary for fuzzy matching
674
  self.normalized_vocab = {self.normalize_for_comparison(w): w for w in self.vocab}
675
 
676
- print(f"📚 VocabularyManager initialized: {len(self.vocab)} words")
677
 
678
  @classmethod
679
  def normalize_for_comparison(cls, word: str) -> str:
@@ -763,9 +815,8 @@ class VocabularyManager:
763
  max_len = max(len(original), len(corrected), 1)
764
  return 1.0 - (dist / max_len)
765
 
766
-
767
  # ═══════════════════════════════════════════════════════════════════════════════
768
- # WORD ALIGNER (Phase 2 - Solutions.md الفكرة 5)
769
  # ═══════════════════════════════════════════════════════════════════════════════
770
 
771
  class WordAligner:
@@ -848,12 +899,26 @@ class WordAligner:
848
  return input_word
849
 
850
  # Case 4: Both OOV
851
- # Take output, usually closer to target even if still OOV
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  return output_word
853
 
854
-
855
  # ═══════════════════════════════════════════════════════════════════════════════
856
- # SPLIT/MERGE SPECIALIST (Phase 2 - Solutions.md الفكرة 4)
857
  # ═══════════════════════════════════════════════════════════════════════════════
858
 
859
  class SplitMergeSpecialist:
@@ -893,15 +958,24 @@ class SplitMergeSpecialist:
893
  self.SEPARABLE_PREFIXES, key=len, reverse=True
894
  )
895
 
 
 
 
 
 
 
 
896
  def split_word(self, word: str) -> str:
897
  """
898
  Try to split an OOV word into IV components.
899
 
900
- STRICT Strategy: Only split when BOTH parts are IV.
901
- This prevents over-splitting like معظم → مع ظم
 
 
902
  """
903
- # Short words: don't split
904
- if len(word) < 4:
905
  return word
906
 
907
  # Already IV: no need to split
@@ -912,17 +986,29 @@ class SplitMergeSpecialist:
912
  if word in self.PROTECTED_WORDS:
913
  return word
914
 
 
 
 
 
 
 
 
 
 
 
 
 
915
  # 1. Try separable prefixes first (higher priority)
916
  for prefix in self.separable_prefixes:
917
- if word.startswith(prefix) and len(word) > len(prefix) + 1:
918
  remainder = word[len(prefix):]
919
 
920
  # Only accept if remainder is IV
921
  if self.vocab.is_iv(remainder):
922
  return f"{prefix} {remainder}"
923
 
924
- # 2. Try all positions - STRICT: BOTH parts must be IV
925
- for i in range(2, len(word) - 1):
926
  left = word[:i]
927
  right = word[i:]
928
 
@@ -932,14 +1018,20 @@ class SplitMergeSpecialist:
932
  # No valid split found
933
  return word
934
 
 
 
 
 
935
  def merge_fragments(self, text: str) -> str:
936
  """
937
  Try to merge adjacent OOV fragments into IV words.
938
 
939
  Key patterns:
940
- 1. Ta-marbuta detachment: السوري ة → السورية (Safe even if السوري is IV)
941
  2. Al- detachment: ال كتاب → الكتاب
942
  3. General OOV+OOV merging: Only if both are OOV and result is IV
 
 
943
  """
944
  words = text.split()
945
  if len(words) < 2:
@@ -986,6 +1078,24 @@ class SplitMergeSpecialist:
986
  result.append(merged)
987
  i += 2
988
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
989
 
990
  result.append(word)
991
  i += 1
@@ -1013,12 +1123,10 @@ class SplitMergeSpecialist:
1013
 
1014
  return ' '.join(processed)
1015
 
1016
-
1017
  # ═══════════════════════════════════════════════════════════════════════════════
1018
- # EDIT DISTANCE CORRECTOR (NEW!)
1019
  # ═══════════════════════════════════════════════════════════════════════════════
1020
 
1021
-
1022
  class EditDistanceCorrector:
1023
  """
1024
  Generates candidates based on Levenshtein distance.
@@ -1089,154 +1197,7 @@ class EditDistanceCorrector:
1089
  return ' '.join(corrected_words)
1090
 
1091
 
1092
- @staticmethod
1093
- def check_word_count(original: str, corrected: str) -> Tuple[bool, str]:
1094
- """Check if word count is reasonable"""
1095
- words_original = original.split()
1096
- words_corrected = corrected.split()
1097
-
1098
- # Allow more flexibility for merged words
1099
- # If original has long words (>7 chars), allow more splits
1100
- has_long_word = any(len(w) > 7 for w in words_original)
1101
- max_expansion = 3 if has_long_word else 1
1102
-
1103
- if len(words_corrected) > len(words_original) + max_expansion:
1104
- return False, "too_many_words"
1105
-
1106
- return True, "valid"
1107
-
1108
- @staticmethod
1109
- def check_token_overlap(original: str, corrected: str) -> Tuple[bool, str]:
1110
- """Check token overlap"""
1111
- tokens_original = set(original.split())
1112
- tokens_corrected = set(corrected.split())
1113
-
1114
- if not tokens_original:
1115
- return True, "valid"
1116
-
1117
- # Skip for short sentences (1-2 words) where edit distance is better
1118
- if len(tokens_original) <= 2:
1119
- return True, "valid"
1120
-
1121
- overlap = len(tokens_original & tokens_corrected) / len(tokens_original)
1122
-
1123
- if overlap < 0.2:
1124
- return False, "low_token_overlap"
1125
-
1126
- return True, "valid"
1127
-
1128
- @staticmethod
1129
- def check_edit_distance_reasonable(original: str, corrected: str) -> Tuple[bool, str]:
1130
- """Check if edit distance is reasonable"""
1131
- distance = OutputValidator.calculate_edit_distance(original, corrected)
1132
- threshold = len(original) * 0.5 # Adaptive threshold
1133
-
1134
- if distance > threshold:
1135
- return False, "excessive_edit_distance"
1136
-
1137
- return True, "valid"
1138
-
1139
- @staticmethod
1140
- def check_length(original: str, corrected: str) -> Tuple[bool, str]:
1141
- """Check if length change is reasonable"""
1142
- max_change = len(original) * 0.25 + 3
1143
-
1144
- if abs(len(corrected) - len(original)) > max_change:
1145
- return False, "excessive_length_change"
1146
-
1147
- return True, "valid"
1148
-
1149
- @staticmethod
1150
- def check_repetition(corrected: str) -> Tuple[bool, str]:
1151
- """Check for suspicious repetitions"""
1152
- # Check for 4+ consecutive identical chars
1153
- if re.search(r'(.)\1{3,}', corrected):
1154
- return False, "excessive_repetition"
1155
-
1156
- return True, "valid"
1157
-
1158
- @staticmethod
1159
- def check_word_quality(corrected: str) -> Tuple[bool, str]:
1160
- """Check for suspicious short words"""
1161
- words = corrected.split()
1162
-
1163
- # Count 1-char words
1164
- single_char_count = sum(1 for w in words if len(w) == 1)
1165
-
1166
- if single_char_count > 2:
1167
- return False, "too_many_short_words"
1168
-
1169
- return True, "valid"
1170
-
1171
- @staticmethod
1172
- def check_word_splitting_quality(corrected: str) -> Tuple[bool, str]:
1173
- """Check if word splitting looks suspicious"""
1174
- words = corrected.split()
1175
-
1176
- # Check for many 2-char words
1177
- two_char_count = sum(1 for w in words if len(w) == 2)
1178
-
1179
- if len(words) > 3 and two_char_count > len(words) * 0.5:
1180
- return False, "suspicious_word_splitting"
1181
-
1182
- return True, "valid"
1183
-
1184
- @staticmethod
1185
- def check_single_chars(corrected: str) -> Tuple[bool, str]:
1186
- """Check for suspicious single characters"""
1187
- words = corrected.split()
1188
-
1189
- for word in words:
1190
- if len(word) == 1 and word not in {'و', 'ب', 'ل', 'ك', 'ف'}:
1191
- return False, "suspicious_short_word"
1192
-
1193
- return True, "valid"
1194
-
1195
- @staticmethod
1196
- def check_word_preservation(original: str, corrected: str) -> Tuple[bool, str]:
1197
- """Check if at least some words are preserved"""
1198
- words_original = set(original.split())
1199
- words_corrected = set(corrected.split())
1200
-
1201
- if len(words_original) <= 1:
1202
- return True, "valid"
1203
-
1204
- # Skip if splitting a single merged word
1205
- if len(words_original) == 1 and len(words_corrected) > 1:
1206
- return True, "valid"
1207
-
1208
- preserved = len(words_original & words_corrected) / len(words_original)
1209
-
1210
- if preserved < 0.1:
1211
- return False, "too_few_preserved_words"
1212
-
1213
- return True, "valid"
1214
-
1215
- @staticmethod
1216
- def validate(original: str, corrected: str, error_type: str = "unknown") -> Tuple[bool, str]:
1217
- """
1218
- Validate model output
1219
- Returns: (is_valid, reason)
1220
- """
1221
- # Run all checks
1222
- checks = [
1223
- OutputValidator.check_character_preservation(original, corrected),
1224
- OutputValidator.check_word_count(original, corrected),
1225
- OutputValidator.check_token_overlap(original, corrected),
1226
- OutputValidator.check_edit_distance_reasonable(original, corrected),
1227
- OutputValidator.check_length(original, corrected),
1228
- OutputValidator.check_repetition(corrected),
1229
- OutputValidator.check_word_quality(corrected),
1230
- OutputValidator.check_word_splitting_quality(corrected),
1231
- OutputValidator.check_single_chars(corrected),
1232
- OutputValidator.check_word_preservation(original, corrected),
1233
- ]
1234
-
1235
- for is_valid, reason in checks:
1236
- if not is_valid:
1237
- return False, reason
1238
-
1239
- return True, "valid"
1240
 
1241
 
1242
 
@@ -1310,7 +1271,7 @@ class ContextualCorrector:
1310
  if candidate not in candidates:
1311
  candidates.append(candidate)
1312
 
1313
- # 2. 🆕 Remove repeated characters (deletion)
1314
  # Fixes: مدررسة -> مدرسة, جميلل -> جميل
1315
  for i in range(len(word) - 1):
1316
  if word[i] == word[i+1]:
@@ -1319,7 +1280,7 @@ class ContextualCorrector:
1319
  if candidate not in candidates:
1320
  candidates.append(candidate)
1321
 
1322
- # 3. 🆕 Edit Distance 1 Candidates (Insertions, Substitutions, Transpositions)
1323
  # Using a restricted set of characters to avoid explosion
1324
  COMMON_CHARS = 'ابتثجحخدذرزسشصضطظعغفقكلمنهويأإآءئؤةى'
1325
 
@@ -1421,84 +1382,8 @@ class ContextualCorrector:
1421
 
1422
  return scores
1423
 
1424
- def correct_word_in_context(self, text: str, position: int, threshold: float = 0.05) -> Tuple[str, dict]:
1425
- """
1426
- Correct a word in context
1427
- Returns: (corrected_word, metadata)
1428
- """
1429
- words = text.split()
1430
- if position >= len(words):
1431
- return words[position] if position < len(words) else "", {}
1432
-
1433
- original_word = words[position]
1434
-
1435
- # Generate candidates
1436
- candidates = self.generate_candidates(original_word)
1437
-
1438
- if len(candidates) == 1:
1439
- return original_word, {'candidates': 1, 'corrected': False}
1440
-
1441
- # Score candidates (batch)
1442
- scores = self.score_candidates_batch(text, position, candidates)
1443
-
1444
- # Find best candidate
1445
- best_word = max(scores, key=scores.get)
1446
- best_score = scores[best_word]
1447
- original_score = scores[original_word]
1448
-
1449
- # Check if original is in vocabulary (Single Token)
1450
- # We need to know if original_score is reliable (IV) or just a prefix score (OOV)
1451
- orig_tokens = self.tokenizer.encode(original_word, add_special_tokens=False)
1452
- is_orig_iv = len(orig_tokens) == 1
1453
-
1454
- # Apply correction logic
1455
- min_abs_improvement = 1e-4
1456
- is_improvement = False
1457
-
1458
- if is_orig_iv:
1459
- # Standard relative improvement for IV words
1460
- if best_score > original_score * (1 + threshold) and \
1461
- best_score > original_score + min_abs_improvement:
1462
- is_improvement = True
1463
- else:
1464
- # Original is OOV/Multi-token (likely specific scoring issue with prefixes like 'ال')
1465
- # If best_word is IV (Single Token), effectively compare its score against an absolute threshold
1466
- # because original_score (prefix) is misleadingly high.
1467
- # We use a stricter absolute threshold for this case to avoid over-correction of valid OOVs.
1468
- oov_correction_threshold = 0.001 # 0.1% probability minimum (Lowered to catch 'الطقس')
1469
-
1470
- # Also ensure best_word is IV (which we forced in generation, but good to be sure)
1471
- best_tokens = self.tokenizer.encode(best_word, add_special_tokens=False)
1472
- is_best_iv = len(best_tokens) == 1
1473
-
1474
- if is_best_iv and best_score > oov_correction_threshold:
1475
- # We ignore original_score here as it's likely just P(prefix)
1476
- is_improvement = True
1477
- elif best_score > original_score * (1 + threshold):
1478
- # Fallback to relative check if both are OOV or logic allows
1479
- is_improvement = True
1480
-
1481
- if best_word != original_word and is_improvement:
1482
- return best_word, {
1483
- 'candidates': len(candidates),
1484
- 'corrected': True,
1485
- 'original_score': original_score,
1486
- 'best_score': best_score,
1487
- 'improvement': best_score - original_score,
1488
- 'is_orig_iv': is_orig_iv
1489
- }
1490
-
1491
- return original_word, {
1492
- 'candidates': len(candidates),
1493
- 'corrected': False,
1494
- 'original_score': original_score
1495
- }
1496
-
1497
  def predict_masked_token(self, text: str, position: int, top_k: int = 5) -> List[Tuple[str, float]]:
1498
- """
1499
- Predict words for a masked position.
1500
- Returns: List of (word, score)
1501
- """
1502
  words = text.split()
1503
  if position >= len(words):
1504
  return []
@@ -1528,85 +1413,83 @@ class ContextualCorrector:
1528
  token_id = top_k_indices[i].item()
1529
  score = top_k_weights[i].item()
1530
  token = self.tokenizer.decode([token_id]).strip()
1531
- # Filter out subwords (starting with ##) and special tokens
1532
  if not token.startswith("##") and token not in self.tokenizer.all_special_tokens:
1533
  results.append((token, score))
1534
 
1535
  return results
1536
 
1537
- def refine_sentence_with_mask(self, text: str, threshold: float = 0.001) -> str:
1538
- """
1539
- Refine sentence by masking weak words and predicting replacements.
1540
- Effectively uses BERT as a contextual dictionary.
1541
  """
1542
  words = text.split()
1543
  refined_words = words.copy()
1544
 
 
 
 
1545
  for i, word in enumerate(words):
 
 
 
 
 
 
 
 
 
 
 
 
1546
  # 1. Check confidence
1547
- # We use score_with_mlm. If score is very low, it's a candidate for refinement.
1548
  current_score = self.score_with_mlm(text, i, word)
1549
 
1550
- # If word is confident enough, skip
1551
- # Threshold needs to be tuned. 0.001 implies 0.1% probability.
1552
  if current_score > threshold:
1553
  continue
1554
 
1555
  # 2. Mask and Predict
1556
  predictions = self.predict_masked_token(text, i, top_k=10)
1557
 
1558
- # 3. Filter and Select
1559
  for pred_word, pred_score in predictions:
1560
  if pred_word == word:
1561
  continue
1562
 
1563
- # STRICTER CONSTRAINTS
1564
-
1565
- # 1. Length Check
1566
- if abs(len(pred_word) - len(word)) > 2:
1567
  continue
1568
 
1569
- # 2. Similarity Check (Edit Distance)
1570
- # We want to fix TYPOS, not semantic hallucinations.
1571
- # So the replacement MUST be structurally similar.
1572
- # Exception: If original word is very short (<3 chars), strict check.
1573
-
1574
  dist = Levenshtein.distance(word, pred_word)
1575
  max_len = max(len(word), len(pred_word))
1576
  similarity = 1.0 - (dist / max_len)
1577
 
 
 
1578
 
1579
- # Minimum similarity required: 0.7 (Much stricter to prevent semantic shift)
1580
- # 'بسرعّة' -> 'بسرعة' (High sim)
1581
- # 'الإسلامية' -> 'التطبيقية' (Low sim - rejected)
1582
- if similarity < 0.7:
1583
  continue
1584
-
1585
- # 3. Score Improvement
1586
- # If current word is total garbage (score < 1e-5), take any reasonable predicted word.
1587
- # If current word has some confidence, only replace if prediction is MUCH better.
1588
 
1589
- # Check if original word is IV and common
 
 
 
 
1590
  is_original_common = current_score > 0.001
1591
 
1592
  if is_original_common:
1593
- # Very strict if original seems okay
1594
- if pred_score > current_score * 500:
1595
  refined_words[i] = pred_word
1596
  break
1597
  else:
1598
- # Looser if original is weak
1599
- if pred_score > current_score * 10 or pred_score > 0.1:
1600
  refined_words[i] = pred_word
1601
- break # Take the top valid prediction
1602
 
1603
  return ' '.join(refined_words)
1604
 
1605
  def calculate_sentence_score(self, text: str) -> float:
1606
- """
1607
- Calculate a 'fluency' score for the sentence using BERT MLM.
1608
- Returns the average probability of each word being predicted in its context.
1609
- """
1610
  words = text.split()
1611
  if not words:
1612
  return 0.0
@@ -1615,8 +1498,6 @@ class ContextualCorrector:
1615
  scored_words = 0
1616
 
1617
  for i, word in enumerate(words):
1618
- # We skip scoring very common stopwords to focus on content/structure?
1619
- # No, keep it simple for now: score everything.
1620
  score = self.score_with_mlm(text, i, word)
1621
  total_score += score
1622
  scored_words += 1
@@ -1626,36 +1507,6 @@ class ContextualCorrector:
1626
 
1627
  return total_score / scored_words
1628
 
1629
- def correct_sentence(self, text: str, threshold: float = 0.01) -> Tuple[str, dict]:
1630
- """
1631
- Correct all words in a sentence
1632
- Returns: (corrected_text, metadata)
1633
- """
1634
- words = text.split()
1635
- corrected_words = []
1636
- corrections = []
1637
-
1638
- for i, word in enumerate(words):
1639
- corrected_word, meta = self.correct_word_in_context(text, i, threshold)
1640
- corrected_words.append(corrected_word)
1641
-
1642
- if meta.get('corrected'):
1643
- corrections.append({
1644
- 'position': i,
1645
- 'original': word,
1646
- 'corrected': corrected_word,
1647
- 'confidence': meta.get('improvement', 0)
1648
- })
1649
-
1650
- corrected_text = ' '.join(corrected_words)
1651
-
1652
- return corrected_text, {
1653
- 'corrections': corrections,
1654
- 'total_words': len(words)
1655
- }
1656
-
1657
-
1658
-
1659
 
1660
  # ═══════════════════════════════════════════════════════════════════════════════
1661
  # MAIN SPELL CHECKER CLASS
@@ -1675,22 +1526,21 @@ class ArabicSpellChecker:
1675
  self.classifier = ErrorClassifier()
1676
  self.rules = RulesBasedCorrector()
1677
  self.validator = OutputValidator()
1678
- self.vocab_manager = VocabularyManager(tokenizer) # Phase 1: Vocabulary Manager
1679
  self.edit_corrector = EditDistanceCorrector(tokenizer) # Edit Distance candidates
1680
- self.split_merge = SplitMergeSpecialist(self.vocab_manager) # Phase 2: Split/Merge
1681
 
1682
- # Phase 2: WordAligner for word-level hybrid corrections
1683
  self.word_aligner = WordAligner(self.vocab_manager)
1684
 
1685
  # Initialize contextual corrector (optional)
1686
  self.use_contextual = use_contextual
1687
  if use_contextual:
1688
  try:
1689
- # Initialize ContextualCorrector
1690
  self.contextual = ContextualCorrector()
1691
- print("✅ Contextual correction enabled")
1692
  except Exception as e:
1693
- print(f"⚠️ Contextual correction disabled: {e}")
1694
  self.contextual = None
1695
  self.use_contextual = False
1696
  else:
@@ -1709,8 +1559,7 @@ class ArabicSpellChecker:
1709
  return text
1710
 
1711
  def _fix_merged_with_errors(self, text: str) -> str:
1712
- """
1713
- 🆕 Fix merged words that contain errors
1714
 
1715
  Examples:
1716
  الممدرسة → المدرسة
@@ -1727,8 +1576,7 @@ class ArabicSpellChecker:
1727
 
1728
 
1729
  def _split_merged_words_linguistic(self, text: str) -> str:
1730
- """
1731
- 🆕 Split merged words using linguistic patterns
1732
 
1733
  Examples:
1734
  كلصباح → كل صباح
@@ -1757,18 +1605,11 @@ class ArabicSpellChecker:
1757
 
1758
  # Pattern 6: على/عن in middle of (merged) words
1759
  text = re.sub(r'([ا-ي]{3,})(على|عن)([ا-ي]{3,})', r'\1 \2 \3', text)
1760
-
1761
- # Pattern 7: بسم الله الرحمن الرحيم (common concatenation)
1762
- text = re.sub(r'\bبسماللهالرحمنالرحيم\b', 'بسم الله الرحمن الرحيم', text)
1763
- text = re.sub(r'\bبسمالله\b', 'بسم الله', text)
1764
- text = re.sub(r'اللهالرحمن', 'الله الرحمن', text)
1765
- text = re.sub(r'الرحمنالرحيم', 'الرحمن الرحيم', text)
1766
-
1767
  return text
1768
 
1769
  def _split_long_words_heuristic(self, text: str, max_length: int = 15) -> str:
1770
- """
1771
- 🆕 Split suspiciously long words using heuristics
1772
  """
1773
  words = text.split()
1774
  result = []
@@ -1805,8 +1646,7 @@ class ArabicSpellChecker:
1805
  return ' '.join(result)
1806
 
1807
  def _normalize_tanween_patterns(self, text: str) -> str:
1808
- """
1809
- 🆕 Normalize tanween patterns
1810
 
1811
  Examples:
1812
  جدأ → جداً
@@ -1834,7 +1674,7 @@ class ArabicSpellChecker:
1834
  text = self.postprocessor.remove_tatweel(text)
1835
  text = self.postprocessor.normalize_special_chars(text)
1836
 
1837
- # 🆕 التحسينات المدمجة (IMPROVEMENTS INTEGRATED!)
1838
  # Fix repeated chars and merged words with errors FIRST
1839
  text = self._fix_repeated_end_chars(text)
1840
  text = self._fix_merged_with_errors(text)
@@ -1859,38 +1699,40 @@ class ArabicSpellChecker:
1859
 
1860
  return text
1861
 
1862
- def _fix_word_split(self, text: str) -> str:
1863
- """Fix over-split words by joining fragments"""
1864
- return self.postprocessor.join_fragments(text)
1865
-
1866
  def postprocess(self, text: str, original: str = "") -> str:
1867
- """Postprocessing pipeline"""
1868
- return self.postprocessor.full_postprocess(text, original)
1869
 
1870
  def model_inference(self, text: str, num_return_sequences: int = 5) -> List[str]:
1871
- """Run seq2seq model inference and return top candidates"""
 
 
1872
  # Tokenize
1873
  inputs = self.tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=128)
1874
  inputs = {k: v.to(self.device) for k, v in inputs.items()}
1875
 
1876
- # Generate (encoder-decoder needs decoder_start_token_id / bos_token_id)
1877
- decoder_start = getattr(
1878
- self.model.config, 'decoder_start_token_id', None
1879
- ) or getattr(self.model.config, 'bos_token_id', None) or self.tokenizer.cls_token_id
1880
- pad_id = getattr(self.model.config, 'pad_token_id', None) or self.tokenizer.pad_token_id
1881
  with torch.no_grad():
1882
  outputs = self.model.generate(
1883
  **inputs,
1884
- max_length=128,
1885
  num_beams=5,
1886
  num_return_sequences=num_return_sequences,
1887
  early_stopping=True,
1888
- decoder_start_token_id=decoder_start,
1889
- pad_token_id=pad_id,
1890
  )
1891
 
1892
  # Decode
1893
- candidates = self.tokenizer.batch_decode(outputs, skip_special_tokens=True)
 
 
 
 
 
 
 
1894
 
1895
  return candidates
1896
 
@@ -1924,36 +1766,56 @@ class ArabicSpellChecker:
1924
  candidates.append(preprocessed_text)
1925
 
1926
  # B. Smart Rules Candidate (Aggressive Heuristic)
1927
- # This helps when the model fails but the rule-based fix is obvious
1928
  rules_candidate = self.rules.advanced_heuristic_repair(text)
1929
  candidates.append(rules_candidate)
1930
 
1931
- # B2. Edit Distance Candidate (NEW!)
1932
- # Tries to fix typos using simple edit distance (Norvig)
1933
  edit_candidate = self.edit_corrector.generate_candidate(text)
1934
  if edit_candidate != text and edit_candidate != rules_candidate:
1935
  candidates.append(edit_candidate)
1936
 
1937
- # B3. Split/Merge Candidate (Phase 2)
1938
- # NOTE: Disabled - caused regression (Hybrid Wins 144→134)
1939
- # split_merge_candidate = self.split_merge.process_text(preprocessed_text)
1940
- # if split_merge_candidate != preprocessed_text and split_merge_candidate not in candidates:
1941
- # candidates.append(split_merge_candidate)
1942
-
1943
  # C. Model Beams
 
1944
  try:
1945
  model_candidates = self.model_inference(preprocessed_text, num_return_sequences=5)
 
1946
  candidates.extend(model_candidates)
1947
 
1948
- # D. Word-Aligned Hybrid Candidate (Phase 2 - Solution 5)
1949
  # Creates a hybrid by selecting best word from each position
1950
- # (OOV input + IV output → take output, IV input + OOV output → keep input)
1951
  if model_candidates:
1952
  hybrid_candidate = self.word_aligner.align_words(preprocessed_text, model_candidates[0])
1953
  if hybrid_candidate not in candidates:
1954
  candidates.append(hybrid_candidate)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1955
  except Exception as e:
1956
- print(f"⚠️ Model inference failed: {e}")
1957
 
1958
  # Remove duplicates while preserving order
1959
  unique_candidates = []
@@ -1964,6 +1826,8 @@ class ArabicSpellChecker:
1964
  seen.add(c)
1965
  candidates = unique_candidates
1966
 
 
 
1967
  # 4. Rerank Candidates
1968
  best_candidate = preprocessed_text
1969
  best_score = -1.0
@@ -1982,7 +1846,7 @@ class ArabicSpellChecker:
1982
  reason = "too_short"
1983
 
1984
  # ═══════════════════════════════════════════════════════════════════════════
1985
- # NEW: VOCABULARY-AWARE ACCEPTANCE (Phase 1 - Key Fix for Raw Wins)
1986
  # ═══════════════════════════════════════════════════════════════════════════
1987
  # Logic: OOV→IV = ACCEPT (boost), IV→OOV = REJECT (penalize)
1988
  # This prevents over-conservative validation from rejecting correct corrections
@@ -2029,26 +1893,35 @@ class ArabicSpellChecker:
2029
  try:
2030
  fluency_score = self.contextual.calculate_sentence_score(cand)
2031
  except Exception as e:
2032
- print(f"⚠️ Scoring failed: {e}")
2033
  fluency_score = 0.5 # Default fallback
2034
  else:
2035
  fluency_score = 1.0
2036
 
2037
- # C. Similarity Score (Damerau-Levenshtein Distance - Phase 1 improvement)
2038
- # Penalize unnecessary changes. Using DL distance: transpositions = 1 edit (not 2)
2039
- # This helps cases like اقصتاديا→اقتصاديا (swap صت→تص counts as 1)
2040
  dist = VocabularyManager.damerau_levenshtein_distance(preprocessed_text, cand)
2041
- # Using preprocessed_text as anchor because it has basic normalization.
2042
- # Comparison with 'original' might penalize fixing harakat/spelling.
2043
-
2044
  max_len = max(len(preprocessed_text), len(cand), 1)
2045
  similarity = 1.0 - (dist / max_len)
2046
 
2047
- # Boost matches
2048
  if cand == preprocessed_text:
2049
  similarity = 1.0
2050
 
2051
- # NEW: HIGH CONFIDENCE GATING (Phase 1/3 - Solution)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2052
  # If model is extremely confident (high fluency) and words are valid, relax validation
2053
  # This allows correcting severe corruptions that fail strict edit distance
2054
  if fluency_score > 0.85 and cand_oov_count == 0:
@@ -2060,24 +1933,16 @@ class ArabicSpellChecker:
2060
  vocab_boost *= 1.2 # Bonus for high confidence
2061
  validity_factor = 1.0 # Reset validity factor
2062
 
2063
- # Final Score
2064
- # Fluency is roughly [0, 1] (prob). Similarity [0, 1].
2065
- # We want to balance staying close to original vs being fluent.
2066
- # If fluency is very low, it's garbage.
2067
- # If similarity is very low, it's hallucination.
2068
-
2069
- # Weighting:
2070
- # We value Similarity highly to be conservative.
2071
- # But we need Fluency to break ties or fix errors.
2072
 
2073
- # New Formula:
2074
- # Score = (Fluency^0.3) * (Similarity^2.0) * Validity * VocabBoost
2075
- # Using exponent to control trade-off.
2076
- # High sim power -> prefers closer matches.
2077
- # Low fluency power -> flattens probability differences (since probs are small).
2078
- # VocabBoost: rewards OOV→IV, penalizes IV→OOV
2079
 
2080
- final_score = (fluency_score ** 0.3) * (similarity ** 3.0) * validity_factor * vocab_boost
2081
 
2082
  candidate_scores.append({
2083
  'text': cand,
@@ -2085,7 +1950,7 @@ class ArabicSpellChecker:
2085
  'reason': reason,
2086
  'fluency': fluency_score,
2087
  'similarity': similarity,
2088
- 'vocab_boost': vocab_boost, # NEW: Track vocab boost
2089
  'input_oov': input_oov_count,
2090
  'cand_oov': cand_oov_count,
2091
  'final_score': final_score
@@ -2095,32 +1960,265 @@ class ArabicSpellChecker:
2095
  best_score = final_score
2096
  best_candidate = cand
2097
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2098
  # 5. Postprocess Winner
2099
  result = self.postprocess(best_candidate, original)
2100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2101
  # 6. Contextual fine-tuning (BERT Masked Refinement)
2102
- # Note: Applying to full sentence (OOV-only mode caused regression due to lack of context)
 
2103
  if self.use_contextual and self.contextual:
2104
  if len(result) > 3:
2105
- result = self.contextual.refine_sentence_with_mask(result)
 
 
 
2106
 
2107
- # 7. Phase 2: Safe Split/Merge Post-processing
2108
  # Only apply merge_fragments (safe: only merges when result is IV)
2109
- # This fixes ta-marbuta detachment like السوري ة → السورية
2110
  result = self.split_merge.merge_fragments(result)
2111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2112
  return result
2113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2114
 
2115
- print("✅ All classes defined successfully!")
2116
- print(" - ErrorType")
2117
- print(" - AraSpellPostProcessor")
2118
- print(" - ErrorClassifier")
2119
- print(" - RulesBasedCorrector")
2120
- print(" - OutputValidator")
2121
- print(" - ContextualCorrector")
2122
- print(" - ArabicSpellChecker")
2123
 
 
 
 
 
2124
 
2125
- print("✅ AraSpell classes loaded successfully")
 
 
2126
 
 
1
+ # AraSpell — Arabic Spell Checker Pipeline
2
+ # Production-ready version
3
 
4
  import re
5
+ import math
6
+ import logging
7
  import torch
8
  import os
9
+ from collections import Counter
10
  from transformers import AutoTokenizer, EncoderDecoderModel
11
  import Levenshtein
12
+ import jellyfish
 
 
 
 
 
 
13
 
14
+ logger = logging.getLogger(__name__)
15
+ logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
16
 
17
  # ═══════════════════════════════════════════════════════════════════════════════
18
+ # LOAD ARABERT SEQ2SEQ MODEL
19
  # ═══════════════════════════════════════════════════════════════════════════════
20
 
21
+ from huggingface_hub import hf_hub_download
22
 
23
+ MODEL_REPO = 'bayan10/AraSpell-Model'
24
+ MODEL_FILENAME = 'last_model.pt'
25
+
26
+ try:
27
+ logger.info(f"Downloading/loading model from Hugging Face: {MODEL_REPO}")
28
+ MODEL_PATH = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILENAME)
29
+ except Exception as e:
30
+ raise RuntimeError(f"Failed to download model from Hugging Face: {e}")
31
+
32
+ MODEL_NAME = 'aubmindlab/bert-base-arabertv02'
33
+
34
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
35
+ model = EncoderDecoderModel.from_encoder_decoder_pretrained(MODEL_NAME, MODEL_NAME)
36
+
37
+ model.config.decoder_start_token_id = tokenizer.cls_token_id
38
+ model.config.pad_token_id = tokenizer.pad_token_id
39
+ model.config.eos_token_id = tokenizer.sep_token_id
40
+ model.generation_config.max_length = 128
41
+ model.generation_config.decoder_start_token_id = tokenizer.cls_token_id
42
+ model.generation_config.pad_token_id = tokenizer.pad_token_id
43
+ model.generation_config.eos_token_id = tokenizer.sep_token_id
44
+
45
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
46
+ checkpoint = torch.load(MODEL_PATH, map_location=device, weights_only=False)
47
+ model.load_state_dict(checkpoint['model_state_dict'], strict=False)
48
+ model = model.to(device)
49
+ model.eval()
50
+
51
+ logger.info(f"Model loaded on {device}, epoch: {checkpoint.get('epoch', 'N/A')}")
52
 
53
  from enum import Enum
54
  from typing import List, Tuple, Optional
 
66
  CLEAN = "clean"
67
 
68
  # ═══════════════════════════════════════════════════════════════════════════════
69
+ # POST PROCESSOR
70
  # ═══════════════════════════════════════════════════════════════════════════════
71
 
72
  class AraSpellPostProcessor:
73
+ """Arabic text post-processing techniques."""
74
 
75
  ARABIC_HARAKAT = 'ًٌٍَُِّْ'
76
  TATWEEL = 'ـ'
 
79
  }
80
  ARABIC_CONSONANTS = set('بتثجحخدذرزسشصضطظعغفقكلمن')
81
 
82
+ # --- Basic Normalization ---
 
 
83
 
84
  @staticmethod
85
  def remove_harakat(text: str) -> str:
 
98
  text = text.replace(old, new)
99
  return text
100
 
101
+ # --- Core Functions ---
 
 
102
 
103
  @staticmethod
104
  def unified_collapse_repeated(text: str) -> str:
105
  """
106
+ Collapse repeated characters.
107
+ Arabic: 3+ consecutive → 1 | Latin: 2+ consecutive → 1
 
108
  """
109
  # Arabic characters: 3+ → 1
110
  text = re.sub(r"([\u0600-\u06FF])\1{2,}", r"\1", text)
 
114
 
115
  return text
116
 
 
 
117
  @staticmethod
118
  def remove_duplicate_words(text: str) -> str:
119
+ """Remove consecutive duplicate words. e.g. كتاب كتاب → كتاب"""
 
 
 
120
  words = text.split()
121
  if len(words) < 2:
122
  return text
 
128
 
129
  return ' '.join(result)
130
 
 
 
131
  @staticmethod
132
  def normalize_spaces(text: str) -> str:
133
+ """Normalize whitespace: multiple spaces, unicode spaces, punctuation spacing."""
 
 
134
  # Multiple spaces → single
135
  text = re.sub(r' +', ' ', text)
136
 
 
164
  i += 1
165
  return ' '.join(result)
166
 
167
+ # --- Hamza & Ta Marbuta Handling ---
 
 
168
 
169
  @staticmethod
170
  def fix_hamza_conservative(text: str) -> str:
171
+ """Conservative Hamza normalization — only at word END, not middle."""
 
 
 
 
 
 
 
172
  words = text.split()
173
  result = []
174
 
 
187
  return ' '.join(result)
188
 
189
  @staticmethod
190
+ def fix_ha_ta_marbuta(text: str, vocab_manager=None) -> str:
191
  """
192
+ Smart ه → ة fix at end of words.
193
+
194
+ Key insight: ه at word end can be:
195
+ - Ta Marbuta (should be ة): المدرسه → المدرسة
196
+ - Possessive pronoun (should stay ه): تحقيقه = his achievement
197
+
198
+ Strategy: Only convert if the ة version is IV (in tokenizer vocab).
199
+ This distinguishes المدرسة (IV) from تحقيقة (not a real word form).
200
+ Without vocab_manager, falls back to original pattern-based approach.
201
  """
202
+ # Protected words: anything containing لله
203
+ PROTECTED_ENDINGS = ['لله']
204
+
205
  words = text.split()
206
  result = []
207
 
208
  for word in words:
209
+ # Skip protected words (Allah-related)
210
+ if any(word.endswith(e) for e in PROTECTED_ENDINGS):
211
+ result.append(word)
212
+ continue
213
+
214
  if len(word) >= 4 and word.endswith('ه'):
215
  # Check if second-to-last char is a consonant
216
  if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS:
217
+ candidate_with_ta = word[:-1] + 'ة'
218
+
219
+ if vocab_manager:
220
+ # SMART MODE: Use vocab to decide
221
+ ta_iv = vocab_manager.is_iv(candidate_with_ta)
222
+ ha_iv = vocab_manager.is_iv(word)
223
+
224
+ if ta_iv:
225
+ # ة version is IV → convert (المدرسه→المدرسة)
226
+ result.append(candidate_with_ta)
227
+ continue
228
+ elif ha_iv:
229
+ # Only ه version is IV → keep ه (possessive: تحقيقه)
230
+ result.append(word)
231
+ continue
232
+ # else: NEITHER is IV → keep original ه
233
+ # (safer than guessing — could be rare possessive)
234
+ else:
235
+ # FALLBACK: No vocab → use original pattern-based approach
236
+ result.append(candidate_with_ta)
237
+ continue
238
  result.append(word)
239
 
240
  return ' '.join(result)
241
 
242
+ # --- Hallucination Removal ---
 
 
243
 
244
  @staticmethod
245
  def remove_hallucinations(text: str) -> str:
246
+ """Remove model hallucinations: duplicate words, trailing 'و' artifacts."""
 
 
 
 
247
  words = text.split()
248
  if not words:
249
  return text
 
295
 
296
  return text
297
 
298
+ # --- Word Splitting & Merging ---
 
 
299
 
300
  @staticmethod
301
  def merge_separated_al(text: str) -> str:
 
304
 
305
  @staticmethod
306
  def join_fragments(text: str) -> str:
307
+ """Join short fragments with validation. e.g. الط + الب → الطالب"""
 
 
 
 
 
308
  words = text.split()
309
  if len(words) < 2:
310
  return text
 
359
 
360
  return ' '.join(result)
361
 
362
+ # --- Main Pipelines ---
 
 
363
 
364
  @staticmethod
365
+ def full_postprocess(text: str, original: str = "", vocab_manager=None) -> str:
366
  """
367
  Apply all post-processing steps (OPTIMIZED ORDER!)
368
+ vocab_manager: optional, enables smart ه/ة handling
369
  """
370
  # 1. Remove hallucinated prefixes
371
  if original:
 
383
  # 5. Fix Hamza (CONSERVATIVE!)
384
  text = AraSpellPostProcessor.fix_hamza_conservative(text)
385
 
386
+ # 6. Fix Ta Marbuta (SMART MODE with vocab_manager!)
387
+ text = AraSpellPostProcessor.fix_ha_ta_marbuta(text, vocab_manager=vocab_manager)
388
 
389
  # 7. Remove word repetition with 'و'
390
  text = AraSpellPostProcessor.remove_word_repetition_with_wa(text)
 
399
 
400
 
401
  # ─────────────────────────────────────────────────────────────────────────────
402
+ # ERROR CLASSIFIER
403
  # ─────────────────────────────────────────────────────────────────────────────
404
 
405
  class ErrorClassifier:
 
444
  else:
445
  return ErrorType.CLEAN
446
 
 
447
  # ═══════════════════════════════════════════════════════════════════════════════
448
+ # RULES-BASED CORRECTOR
 
 
 
 
 
449
  # ═══════════════════════════════════════════════════════════════════════════════
450
 
451
  class RulesBasedCorrector:
452
+ """Rules-based correction with keyboard proximity mapping."""
453
 
454
  # Persian/Urdu → Arabic mapping
455
  SUBSTITUTION_MAP = {
 
470
  'لل'
471
  }
472
 
473
+ # Keyboard Proximity Mapping
474
+ # Arabic keyboard layout adjacency
475
+ KEYBOARD_NEIGHBORS = {
476
+ 'ض': ['ص', 'ق'],
477
+ 'ص': ['ض', 'ث', 'ق'],
478
+ 'ث': ['ص', 'ق'],
479
+ 'ق': ['ض', 'ص', 'ث', 'ف', 'غ'],
480
+ 'ف': ['ق', 'غ', 'ع', 'ب'],
481
+ 'غ': ['ق', 'ف', 'ع', 'ه'],
482
+ 'ع': ['ف', 'غ', 'ه', 'خ'],
483
+ 'ه': ['غ', 'ع', 'خ', 'ح'],
484
+ 'خ': ['ع', 'ه', 'ح', 'ج'],
485
+ 'ح': ['ه', 'خ', 'ج'],
486
+ 'ج': ['خ', 'ح', 'د'],
487
+ 'د': ['ج', 'ذ'],
488
+ 'ذ': ['د'],
489
+ 'ش': ['س', 'ي', 'ئ'],
490
+ 'س': ['ش', 'ي', 'ب'],
491
+ 'ي': ['ش', 'س', 'ب', 'ت'],
492
+ 'ب': ['ي', 'س', 'ف', 'ل', 'ن'],
493
+ 'ل': ['ب', 'ا', 'ن', 'م'],
494
+ 'ا': ['ل', 'ت', 'م'],
495
+ 'ت': ['ي', 'ا', 'ن'],
496
+ 'ن': ['ب', 'ل', 'ت', 'م', 'ك'],
497
+ 'م': ['ل', 'ا', 'ن', 'ك'],
498
+ 'ك': ['ن', 'م', 'ط'],
499
+ 'ط': ['ك', 'ظ'],
500
+ 'ظ': ['ط'],
501
+ 'ئ': ['ش', 'ء', 'ر'],
502
+ 'ء': ['ئ', 'ؤ'],
503
+ 'ؤ': ['ء', 'ر'],
504
+ 'ر': ['ئ', 'ؤ', 'لا', 'ى', 'ز'],
505
+ 'لا': ['ر', 'ى'],
506
+ 'ى': ['ر', 'لا', 'ة', 'ز'],
507
+ 'ة': ['ى', 'و', 'ز'],
508
+ 'و': ['ة', 'ز'],
509
+ 'ز': ['ر', 'ى', 'ة', 'و'],
510
+ # Alif variants
511
+ 'أ': ['ا', 'إ', 'آ'],
512
+ 'إ': ['ا', 'أ'],
513
+ 'آ': ['ا', 'أ'],
514
+ }
515
+
516
+ @staticmethod
517
+ def is_keyboard_neighbor(char1: str, char2: str) -> bool:
518
+ """Check if two Arabic chars are adjacent on keyboard."""
519
+ neighbors = RulesBasedCorrector.KEYBOARD_NEIGHBORS.get(char1, [])
520
+ return char2 in neighbors
521
+
522
  @staticmethod
523
  def fix_char_substitution(text: str) -> str:
524
  """Replace Persian/Urdu characters with Arabic"""
 
528
 
529
  @staticmethod
530
  def fix_char_repetition(text: str) -> str:
531
+ """Remove excessive character repetition (3+ consecutive → 1)."""
 
 
 
532
  # Only collapse 3+ repetitions (not 2+)
533
  text = re.sub(r'([^\d\s])\1{2,}', r'\1', text)
534
  return text
 
653
  if not corrected or not corrected.strip():
654
  return False, "empty_output"
655
 
656
+ # Space Leniency: if ONLY difference is whitespace → accept
 
 
 
 
657
  original_no_space = original.replace(' ', '').replace('\u200c', '') # Also handle ZWNJ
658
  corrected_no_space = corrected.replace(' ', '').replace('\u200c', '')
659
 
 
692
 
693
  return True, "valid"
694
 
 
695
  # ═══════════════════════════════════════════════════════════════════════════════
696
+ # VOCABULARY MANAGER
697
  # ═══════════════════════════════════════════════════════════════════════════════
698
 
699
  class VocabularyManager:
 
725
  # Build normalized vocabulary for fuzzy matching
726
  self.normalized_vocab = {self.normalize_for_comparison(w): w for w in self.vocab}
727
 
728
+ logger.info(f"VocabularyManager initialized: {len(self.vocab)} words")
729
 
730
  @classmethod
731
  def normalize_for_comparison(cls, word: str) -> str:
 
815
  max_len = max(len(original), len(corrected), 1)
816
  return 1.0 - (dist / max_len)
817
 
 
818
  # ═══════════════════════════════════════════════════════════════════════════════
819
+ # WORD ALIGNER
820
  # ═══════════════════════════════════════════════════════════════════════════════
821
 
822
  class WordAligner:
 
899
  return input_word
900
 
901
  # Case 4: Both OOV
902
+ # Subword-level correction
903
+ # If words are similar length, try character-level blending to find IV
904
+ if len(input_word) == len(output_word) and len(input_word) >= 3:
905
+ # Try replacing one char at a time from output into input
906
+ for i in range(len(input_word)):
907
+ if input_word[i] != output_word[i]:
908
+ # Try input with this one char from output
909
+ hybrid = input_word[:i] + output_word[i] + input_word[i+1:]
910
+ if self.vocab.is_iv(hybrid):
911
+ return hybrid
912
+ # Try output with this one char from input
913
+ hybrid2 = output_word[:i] + input_word[i] + output_word[i+1:]
914
+ if self.vocab.is_iv(hybrid2):
915
+ return hybrid2
916
+
917
+ # Default: Take output, usually closer to target even if still OOV
918
  return output_word
919
 
 
920
  # ═══════════════════════════════════════════════════════════════════════════════
921
+ # SPLIT/MERGE SPECIALIST
922
  # ═══════════════════════════════════════════════════════════════════════════════
923
 
924
  class SplitMergeSpecialist:
 
958
  self.SEPARABLE_PREFIXES, key=len, reverse=True
959
  )
960
 
961
+ # Attached prefix patterns that should NOT be split (normal Arabic word formations)
962
+ ATTACHED_PREFIXES = [
963
+ 'وال', 'بال', 'فال', 'كال', 'لل', # Conjunction/Preposition + Article
964
+ 'وب', 'وف', 'ول', 'وك', 'وم', 'ون', # Conjunction + Preposition
965
+ 'فب', 'فل', 'فك', 'فم', # Conjunction + Preposition
966
+ ]
967
+
968
  def split_word(self, word: str) -> str:
969
  """
970
  Try to split an OOV word into IV components.
971
 
972
+ Strict Strategy:
973
+ - Only split when BOTH parts are IV
974
+ - Protect attached prefix patterns (وال، بال، etc.)
975
+ - Minimum part lengths to prevent micro-splits
976
  """
977
+ # Short words: don't split (increased from 4 to 5 for safety)
978
+ if len(word) < 5:
979
  return word
980
 
981
  # Already IV: no need to split
 
986
  if word in self.PROTECTED_WORDS:
987
  return word
988
 
989
+ # Protected prefix patterns (وال، بال، فال، etc.)
990
+ # These are normal Arabic word formations, NOT merge errors
991
+ for prefix in self.ATTACHED_PREFIXES:
992
+ if word.startswith(prefix):
993
+ remainder = word[len(prefix):]
994
+ # If the remainder (without the prefix) is IV, this is a valid prefixed word
995
+ if self.vocab.is_iv(remainder):
996
+ return word # Don't split — it's prefix+valid_word
997
+ # Also check with article: e.g. والخصوصي → وال+خصوصي, check خصوصي
998
+ if prefix.endswith('ال') and self.vocab.is_iv(remainder):
999
+ return word
1000
+
1001
  # 1. Try separable prefixes first (higher priority)
1002
  for prefix in self.separable_prefixes:
1003
+ if word.startswith(prefix) and len(word) > len(prefix) + 2: # Remainder must be > 2 chars
1004
  remainder = word[len(prefix):]
1005
 
1006
  # Only accept if remainder is IV
1007
  if self.vocab.is_iv(remainder):
1008
  return f"{prefix} {remainder}"
1009
 
1010
+ # 2. Try all positions - STRICT: BOTH parts must be IV AND both >= 3 chars
1011
+ for i in range(3, len(word) - 2): # Both parts at least 3 chars
1012
  left = word[:i]
1013
  right = word[i:]
1014
 
 
1018
  # No valid split found
1019
  return word
1020
 
1021
+ # Common Arabic pronoun/possessive suffixes (2-3 chars)
1022
+ # These are often incorrectly split from their host word
1023
+ PRONOUN_SUFFIXES = {'كم', 'هم', 'ها', 'هن', 'كن', 'نا', 'هما', 'كما', 'تم', 'تن'}
1024
+
1025
  def merge_fragments(self, text: str) -> str:
1026
  """
1027
  Try to merge adjacent OOV fragments into IV words.
1028
 
1029
  Key patterns:
1030
+ 1. Ta-marbuta detachment: السوري ة → السورية
1031
  2. Al- detachment: ال كتاب → الكتاب
1032
  3. General OOV+OOV merging: Only if both are OOV and result is IV
1033
+ 4. Short OOV fragment: 1-2 char OOV + next → IV
1034
+ 5. Pronoun suffix reattachment: علي كم → عليكم
1035
  """
1036
  words = text.split()
1037
  if len(words) < 2:
 
1078
  result.append(merged)
1079
  i += 2
1080
  continue
1081
+
1082
+ # Pattern 5: Pronoun suffix reattachment
1083
+ # Fixes over-splitting: علي كم → عليكم
1084
+ if next_word in self.PRONOUN_SUFFIXES:
1085
+ if self.vocab.is_iv(merged) and not self.vocab.is_iv(word):
1086
+ result.append(merged)
1087
+ i += 2
1088
+ continue
1089
+
1090
+ # Pattern 6: Short fragment merge
1091
+ # Merges two short words when combined they form a valid longer word
1092
+ # Fixes: علي كم → عليكم, ويت أمل → ويتأمل, المد فتر → المدفتر
1093
+ # Condition: both words ≤ 3 chars, merged ≥ 5 chars and IV
1094
+ if len(word) <= 3 and len(next_word) <= 3:
1095
+ if len(merged) >= 5 and self.vocab.is_iv(merged):
1096
+ result.append(merged)
1097
+ i += 2
1098
+ continue
1099
 
1100
  result.append(word)
1101
  i += 1
 
1123
 
1124
  return ' '.join(processed)
1125
 
 
1126
  # ═══════════════════════════════════════════════════════════════════════════════
1127
+ # EDIT DISTANCE CORRECTOR
1128
  # ═══════════════════════════════════════════════════════════════════════════════
1129
 
 
1130
  class EditDistanceCorrector:
1131
  """
1132
  Generates candidates based on Levenshtein distance.
 
1197
  return ' '.join(corrected_words)
1198
 
1199
 
1200
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1201
 
1202
 
1203
 
 
1271
  if candidate not in candidates:
1272
  candidates.append(candidate)
1273
 
1274
+ # 2. Remove repeated characters (deletion)
1275
  # Fixes: مدررسة -> مدرسة, جميلل -> جميل
1276
  for i in range(len(word) - 1):
1277
  if word[i] == word[i+1]:
 
1280
  if candidate not in candidates:
1281
  candidates.append(candidate)
1282
 
1283
+ # 3. Edit Distance 1 Candidates (Insertions, Substitutions, Transpositions)
1284
  # Using a restricted set of characters to avoid explosion
1285
  COMMON_CHARS = 'ابتثجحخدذرزسشصضطظعغفقكلمنهويأإآءئؤةى'
1286
 
 
1382
 
1383
  return scores
1384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1385
  def predict_masked_token(self, text: str, position: int, top_k: int = 5) -> List[Tuple[str, float]]:
1386
+ """Predict words for a masked position. Returns list of (word, score)."""
 
 
 
1387
  words = text.split()
1388
  if position >= len(words):
1389
  return []
 
1413
  token_id = top_k_indices[i].item()
1414
  score = top_k_weights[i].item()
1415
  token = self.tokenizer.decode([token_id]).strip()
 
1416
  if not token.startswith("##") and token not in self.tokenizer.all_special_tokens:
1417
  results.append((token, score))
1418
 
1419
  return results
1420
 
1421
+ def refine_sentence_with_mask(self, text: str, threshold: float = 0.001, vocab_manager=None, raw_model_output=None) -> str:
1422
+ """Refine sentence by masking weak words and predicting replacements.
1423
+ IV-Safe + Strict similarity + BERT Kill Switch.
 
1424
  """
1425
  words = text.split()
1426
  refined_words = words.copy()
1427
 
1428
+ # Build set of raw model words for kill switch
1429
+ raw_words = raw_model_output.split() if raw_model_output else []
1430
+
1431
  for i, word in enumerate(words):
1432
+ # IV-Safe check - NEVER replace IV words
1433
+ if vocab_manager and vocab_manager.is_iv(word):
1434
+ continue
1435
+
1436
+ # BERT Kill Switch: skip words matching raw model output
1437
+ if i < len(raw_words) and word == raw_words[i]:
1438
+ continue
1439
+
1440
+ # Skip very short words (prepositions etc)
1441
+ if len(word) <= 2:
1442
+ continue
1443
+
1444
  # 1. Check confidence
 
1445
  current_score = self.score_with_mlm(text, i, word)
1446
 
 
 
1447
  if current_score > threshold:
1448
  continue
1449
 
1450
  # 2. Mask and Predict
1451
  predictions = self.predict_masked_token(text, i, top_k=10)
1452
 
1453
+ # 3. Filter and Select (strict)
1454
  for pred_word, pred_score in predictions:
1455
  if pred_word == word:
1456
  continue
1457
 
1458
+ if abs(len(pred_word) - len(word)) > 1:
 
 
 
1459
  continue
1460
 
1461
+ # Similarity Check (0.90 minimum)
 
 
 
 
1462
  dist = Levenshtein.distance(word, pred_word)
1463
  max_len = max(len(word), len(pred_word))
1464
  similarity = 1.0 - (dist / max_len)
1465
 
1466
+ if similarity < 0.90:
1467
+ continue
1468
 
1469
+ # Must be IV
1470
+ if vocab_manager and vocab_manager.is_oov(pred_word):
 
 
1471
  continue
 
 
 
 
1472
 
1473
+ # Minimum absolute confidence gate (12%)
1474
+ if pred_score < 0.12:
1475
+ continue
1476
+
1477
+ # Score Improvement
1478
  is_original_common = current_score > 0.001
1479
 
1480
  if is_original_common:
1481
+ if pred_score > current_score * 1000:
 
1482
  refined_words[i] = pred_word
1483
  break
1484
  else:
1485
+ if pred_score > current_score * 50 and pred_score > 0.2:
 
1486
  refined_words[i] = pred_word
1487
+ break
1488
 
1489
  return ' '.join(refined_words)
1490
 
1491
  def calculate_sentence_score(self, text: str) -> float:
1492
+ """Calculate fluency score using BERT MLM average word probability."""
 
 
 
1493
  words = text.split()
1494
  if not words:
1495
  return 0.0
 
1498
  scored_words = 0
1499
 
1500
  for i, word in enumerate(words):
 
 
1501
  score = self.score_with_mlm(text, i, word)
1502
  total_score += score
1503
  scored_words += 1
 
1507
 
1508
  return total_score / scored_words
1509
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1510
 
1511
  # ═══════════════════════════════════════════════════════════════════════════════
1512
  # MAIN SPELL CHECKER CLASS
 
1526
  self.classifier = ErrorClassifier()
1527
  self.rules = RulesBasedCorrector()
1528
  self.validator = OutputValidator()
1529
+ self.vocab_manager = VocabularyManager(tokenizer)
1530
  self.edit_corrector = EditDistanceCorrector(tokenizer) # Edit Distance candidates
1531
+ self.split_merge = SplitMergeSpecialist(self.vocab_manager)
1532
 
1533
+ # WordAligner for word-level hybrid corrections
1534
  self.word_aligner = WordAligner(self.vocab_manager)
1535
 
1536
  # Initialize contextual corrector (optional)
1537
  self.use_contextual = use_contextual
1538
  if use_contextual:
1539
  try:
 
1540
  self.contextual = ContextualCorrector()
1541
+ logger.info("Contextual correction enabled")
1542
  except Exception as e:
1543
+ logger.warning(f"Contextual correction disabled: {e}")
1544
  self.contextual = None
1545
  self.use_contextual = False
1546
  else:
 
1559
  return text
1560
 
1561
  def _fix_merged_with_errors(self, text: str) -> str:
1562
+ """ Fix merged words that contain errors
 
1563
 
1564
  Examples:
1565
  الممدرسة → المدرسة
 
1576
 
1577
 
1578
  def _split_merged_words_linguistic(self, text: str) -> str:
1579
+ """ Split merged words using linguistic patterns
 
1580
 
1581
  Examples:
1582
  كلصباح → كل صباح
 
1605
 
1606
  # Pattern 6: على/عن in middle of (merged) words
1607
  text = re.sub(r'([ا-ي]{3,})(على|عن)([ا-ي]{3,})', r'\1 \2 \3', text)
1608
+
 
 
 
 
 
 
1609
  return text
1610
 
1611
  def _split_long_words_heuristic(self, text: str, max_length: int = 15) -> str:
1612
+ """ Split suspiciously long words using heuristics
 
1613
  """
1614
  words = text.split()
1615
  result = []
 
1646
  return ' '.join(result)
1647
 
1648
  def _normalize_tanween_patterns(self, text: str) -> str:
1649
+ """ Normalize tanween patterns
 
1650
 
1651
  Examples:
1652
  جدأ → جداً
 
1674
  text = self.postprocessor.remove_tatweel(text)
1675
  text = self.postprocessor.normalize_special_chars(text)
1676
 
1677
+ # Integrated improvements
1678
  # Fix repeated chars and merged words with errors FIRST
1679
  text = self._fix_repeated_end_chars(text)
1680
  text = self._fix_merged_with_errors(text)
 
1699
 
1700
  return text
1701
 
 
 
 
 
1702
  def postprocess(self, text: str, original: str = "") -> str:
1703
+ """Postprocessing pipeline — passes vocab_manager for smart ه/ة handling"""
1704
+ return self.postprocessor.full_postprocess(text, original, vocab_manager=self.vocab_manager)
1705
 
1706
  def model_inference(self, text: str, num_return_sequences: int = 5) -> List[str]:
1707
+ """Run seq2seq model inference and return top candidates.
1708
+ Also extracts beam scores (token-level probabilities) for diagnostics.
1709
+ """
1710
  # Tokenize
1711
  inputs = self.tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=128)
1712
  inputs = {k: v.to(self.device) for k, v in inputs.items()}
1713
 
1714
+ # Generate with beam search
1715
+ # Keeping 5 beams as model was trained/optimized for this
1716
+ # Keeping 5 beams as model was trained/optimized for this
 
 
1717
  with torch.no_grad():
1718
  outputs = self.model.generate(
1719
  **inputs,
 
1720
  num_beams=5,
1721
  num_return_sequences=num_return_sequences,
1722
  early_stopping=True,
1723
+ return_dict_in_generate=True,
1724
+ output_scores=True
1725
  )
1726
 
1727
  # Decode
1728
+ candidates = self.tokenizer.batch_decode(outputs.sequences, skip_special_tokens=True)
1729
+
1730
+ # Store beam scores for potential use
1731
+ self._last_beam_scores = {}
1732
+ if hasattr(outputs, 'sequences_scores') and outputs.sequences_scores is not None:
1733
+ scores = outputs.sequences_scores.tolist()
1734
+ for cand, score in zip(candidates, scores):
1735
+ self._last_beam_scores[cand] = score
1736
 
1737
  return candidates
1738
 
 
1766
  candidates.append(preprocessed_text)
1767
 
1768
  # B. Smart Rules Candidate (Aggressive Heuristic)
 
1769
  rules_candidate = self.rules.advanced_heuristic_repair(text)
1770
  candidates.append(rules_candidate)
1771
 
1772
+ # B2. Edit Distance Candidate
 
1773
  edit_candidate = self.edit_corrector.generate_candidate(text)
1774
  if edit_candidate != text and edit_candidate != rules_candidate:
1775
  candidates.append(edit_candidate)
1776
 
 
 
 
 
 
 
1777
  # C. Model Beams
1778
+ raw_model_output = None # Track for safety net
1779
  try:
1780
  model_candidates = self.model_inference(preprocessed_text, num_return_sequences=5)
1781
+ raw_model_output = model_candidates[0] if model_candidates else None
1782
  candidates.extend(model_candidates)
1783
 
1784
+ # D. Word-Aligned Hybrid Candidate
1785
  # Creates a hybrid by selecting best word from each position
 
1786
  if model_candidates:
1787
  hybrid_candidate = self.word_aligner.align_words(preprocessed_text, model_candidates[0])
1788
  if hybrid_candidate not in candidates:
1789
  candidates.append(hybrid_candidate)
1790
+
1791
+ # E. Word-Aligned with ALL top beams (not just beam 0)
1792
+ for beam in model_candidates[1:3]: # Top 3 beams
1793
+ hybrid_beam = self.word_aligner.align_words(preprocessed_text, beam)
1794
+ if hybrid_beam not in candidates:
1795
+ candidates.append(hybrid_beam)
1796
+
1797
+ # D2. Token-level Voting Candidate
1798
+ # Majority-vote each token across all beams
1799
+ if model_candidates and len(model_candidates) >= 3:
1800
+ try:
1801
+ beam_word_lists = [c.split() for c in model_candidates]
1802
+ max_words = max(len(wl) for wl in beam_word_lists)
1803
+ voted_words = []
1804
+ for pos in range(max_words):
1805
+ words_at_pos = []
1806
+ for wl in beam_word_lists:
1807
+ if pos < len(wl):
1808
+ words_at_pos.append(wl[pos])
1809
+ if words_at_pos:
1810
+ most_common = Counter(words_at_pos).most_common(1)[0][0]
1811
+ voted_words.append(most_common)
1812
+ voted_candidate = ' '.join(voted_words)
1813
+ if voted_candidate not in candidates:
1814
+ candidates.append(voted_candidate)
1815
+ except Exception:
1816
+ pass
1817
  except Exception as e:
1818
+ logger.warning(f"Model inference failed: {e}")
1819
 
1820
  # Remove duplicates while preserving order
1821
  unique_candidates = []
 
1826
  seen.add(c)
1827
  candidates = unique_candidates
1828
 
1829
+
1830
+
1831
  # 4. Rerank Candidates
1832
  best_candidate = preprocessed_text
1833
  best_score = -1.0
 
1846
  reason = "too_short"
1847
 
1848
  # ═══════════════════════════════════════════════════════════════════════════
1849
+ # VOCABULARY-AWARE ACCEPTANCE
1850
  # ═══════════════════════════════════════════════════════════════════════════
1851
  # Logic: OOV→IV = ACCEPT (boost), IV→OOV = REJECT (penalize)
1852
  # This prevents over-conservative validation from rejecting correct corrections
 
1893
  try:
1894
  fluency_score = self.contextual.calculate_sentence_score(cand)
1895
  except Exception as e:
1896
+ logger.warning(f"Scoring failed: {e}")
1897
  fluency_score = 0.5 # Default fallback
1898
  else:
1899
  fluency_score = 1.0
1900
 
1901
+ # C. Similarity Score (Damerau-Levenshtein Distance)
 
 
1902
  dist = VocabularyManager.damerau_levenshtein_distance(preprocessed_text, cand)
 
 
 
1903
  max_len = max(len(preprocessed_text), len(cand), 1)
1904
  similarity = 1.0 - (dist / max_len)
1905
 
1906
+ # Boost exact matches
1907
  if cand == preprocessed_text:
1908
  similarity = 1.0
1909
 
1910
+ # Keyboard Proximity Bonus
1911
+ # If changes between input and candidate are keyboard-adjacent,
1912
+ # it's more likely a typo fix (give bonus)
1913
+ keyboard_bonus = 1.0
1914
+ input_words = preprocessed_text.split()
1915
+ cand_words = cand.split()
1916
+ if len(input_words) == len(cand_words):
1917
+ for iw, cw in zip(input_words, cand_words):
1918
+ if iw != cw and len(iw) == len(cw):
1919
+ # Check char-by-char differences
1920
+ for ic, cc in zip(iw, cw):
1921
+ if ic != cc and RulesBasedCorrector.is_keyboard_neighbor(ic, cc):
1922
+ keyboard_bonus *= 1.05 # 5% bonus per keyboard-adjacent fix
1923
+
1924
+ # HIGH CONFIDENCE GATING
1925
  # If model is extremely confident (high fluency) and words are valid, relax validation
1926
  # This allows correcting severe corruptions that fail strict edit distance
1927
  if fluency_score > 0.85 and cand_oov_count == 0:
 
1933
  vocab_boost *= 1.2 # Bonus for high confidence
1934
  validity_factor = 1.0 # Reset validity factor
1935
 
1936
+ # Final Score = (Fluency^0.3) * (Similarity^3.0) * Validity * VocabBoost * KeyboardBonus * BeamBoost
1937
+ fluency_exp = 0.3
1938
+ similarity_exp = 3.0
 
 
 
 
 
 
1939
 
1940
+ # Beam 0 Boost — model's top beam gets 15% priority
1941
+ beam_boost = 1.0
1942
+ if raw_model_output and cand == raw_model_output:
1943
+ beam_boost = 1.15
 
 
1944
 
1945
+ final_score = (fluency_score ** fluency_exp) * (similarity ** similarity_exp) * validity_factor * vocab_boost * keyboard_bonus * beam_boost
1946
 
1947
  candidate_scores.append({
1948
  'text': cand,
 
1950
  'reason': reason,
1951
  'fluency': fluency_score,
1952
  'similarity': similarity,
1953
+ 'vocab_boost': vocab_boost,
1954
  'input_oov': input_oov_count,
1955
  'cand_oov': cand_oov_count,
1956
  'final_score': final_score
 
1960
  best_score = final_score
1961
  best_candidate = cand
1962
 
1963
+ # ═══════════════════════════════════════════════════════════════════════════
1964
+ # --- Output Quality Scoring (Minimum Score Threshold) ---
1965
+ # If ALL candidates scored poorly, the correction is unreliable → keep input
1966
+ # ═══════════════════════════════════════════════════════════════════════════
1967
+ if best_candidate != preprocessed_text:
1968
+ # Check: did the best candidate actually get a decent score?
1969
+ # The preprocessed input (candidate 0) is always in the pool.
1970
+ # If the best candidate barely beats preprocessed_text, it might not be trustworthy.
1971
+ preprocessed_score = 0.0
1972
+ for cs in candidate_scores:
1973
+ if cs['text'] == preprocessed_text:
1974
+ preprocessed_score = cs['final_score']
1975
+ break
1976
+
1977
+ # If best score is less than 1.05x the preprocessed score AND
1978
+ # the best candidate introduced OOV words → fall back to preprocessed
1979
+ if preprocessed_score > 0 and best_score < preprocessed_score * 1.05:
1980
+ best_oov = self.vocab_manager.count_oov_words(best_candidate)
1981
+ prep_oov = self.vocab_manager.count_oov_words(preprocessed_text)
1982
+ if best_oov > prep_oov:
1983
+ best_candidate = preprocessed_text
1984
+ best_score = preprocessed_score
1985
+
1986
+ # ═══════════════════════════════════════════════════════════════════════════
1987
+ # --- Contextual Validation Layer ---
1988
+ # Compare fluency of input vs best candidate
1989
+ # If correction made text LESS fluent → reject the correction
1990
+ # ═══════════════════════════════════════════════════════════════════════════
1991
+ if best_candidate != preprocessed_text and self.use_contextual and self.contextual:
1992
+ try:
1993
+ input_fluency = self.contextual.calculate_sentence_score(preprocessed_text)
1994
+ best_fluency = 0.0
1995
+ for cs in candidate_scores:
1996
+ if cs['text'] == best_candidate:
1997
+ best_fluency = cs['fluency']
1998
+ break
1999
+
2000
+ # If input is significantly more fluent than best candidate
2001
+ # AND both have similar OOV counts → prefer input
2002
+ if input_fluency > 0 and best_fluency > 0:
2003
+ if input_fluency > best_fluency * 1.5: # Input 50% more fluent
2004
+ input_oov = self.vocab_manager.count_oov_words(preprocessed_text)
2005
+ best_oov = self.vocab_manager.count_oov_words(best_candidate)
2006
+ if input_oov <= best_oov:
2007
+ # Input is more fluent AND has fewer/equal OOV → keep input
2008
+ best_candidate = preprocessed_text
2009
+ except Exception:
2010
+ pass # Contextual validation is optional
2011
+
2012
  # 5. Postprocess Winner
2013
  result = self.postprocess(best_candidate, original)
2014
 
2015
+ # 5.5 IV-Safe Postprocessing Check
2016
+ # If postprocessing changed an IV word to OOV, revert that word
2017
+ if result != best_candidate:
2018
+ result_words = result.split()
2019
+ best_words = best_candidate.split()
2020
+ if len(result_words) == len(best_words):
2021
+ fixed_words = []
2022
+ input_words_pp = preprocessed_text.split()
2023
+ for idx_fw, (rw, bw) in enumerate(zip(result_words, best_words)):
2024
+ if rw != bw:
2025
+ # Postprocessor changed this word
2026
+ bw_iv = self.vocab_manager.is_iv(bw)
2027
+ rw_iv = self.vocab_manager.is_iv(rw)
2028
+ if bw_iv and not rw_iv:
2029
+ # IV → OOV: revert to pre-postprocess version
2030
+ fixed_words.append(bw)
2031
+ elif bw_iv and rw_iv:
2032
+ # Postprocess Distance Guard
2033
+ # DISABLED: Caused word-level regression. When both are IV,
2034
+ # the postprocessor's choice (rw) is usually better because
2035
+ # it applies Arabic-specific rules (hamza, ta marbuta).
2036
+ fixed_words.append(rw)
2037
+ else:
2038
+ fixed_words.append(rw)
2039
+ else:
2040
+ fixed_words.append(rw)
2041
+ result = ' '.join(fixed_words)
2042
+
2043
  # 6. Contextual fine-tuning (BERT Masked Refinement)
2044
+ # IV-Safe mode - pass vocab_manager to protect IV words
2045
+ # BERT Kill Switch - also pass raw_model_output to protect model-confident words
2046
  if self.use_contextual and self.contextual:
2047
  if len(result) > 3:
2048
+ result = self.contextual.refine_sentence_with_mask(
2049
+ result, vocab_manager=self.vocab_manager,
2050
+ raw_model_output=raw_model_output
2051
+ )
2052
 
2053
+ # 7. Safe Split/Merge Post-processing
2054
  # Only apply merge_fragments (safe: only merges when result is IV)
 
2055
  result = self.split_merge.merge_fragments(result)
2056
 
2057
+ # ═══════════════════════════════════════════════════════════════════════════
2058
+ # VALIDATION & QUALITY CHECKS
2059
+ # ═══════════════════════════════════════════════════════════════════════════
2060
+
2061
+ # 8. Output Stability Test (Solution 30)
2062
+ # If correcting the output again changes it → unstable correction → reject
2063
+ # Stable corrections are idempotent: correct(correct(x)) == correct(x)
2064
+ if result != preprocessed_text and raw_model_output:
2065
+ try:
2066
+ # Quick stability check: run the result through preprocessing only
2067
+ # (full model inference would be too slow)
2068
+ re_preprocessed = self.preprocess(result)
2069
+
2070
+ # If re-preprocessing changes the result significantly, it was unstable
2071
+ stability_dist = VocabularyManager.damerau_levenshtein_distance(result, re_preprocessed)
2072
+ result_len = max(len(result), 1)
2073
+
2074
+ if stability_dist > 0:
2075
+ # Result is not stable under re-preprocessing
2076
+ stability_ratio = stability_dist / result_len
2077
+
2078
+ if stability_ratio > 0.15: # More than 15% changed → very unstable
2079
+ # Fall back to raw model output if it's more stable
2080
+ raw_re = self.preprocess(raw_model_output)
2081
+ raw_stability = VocabularyManager.damerau_levenshtein_distance(
2082
+ raw_model_output, raw_re
2083
+ ) / max(len(raw_model_output), 1)
2084
+
2085
+ if raw_stability < stability_ratio:
2086
+ # Raw is more stable → use it
2087
+ raw_oov = self.vocab_manager.count_oov_words(raw_model_output)
2088
+ our_oov = self.vocab_manager.count_oov_words(result)
2089
+ if raw_oov <= our_oov:
2090
+ result = raw_model_output
2091
+ except Exception:
2092
+ pass # Stability check is optional, don't break pipeline
2093
+
2094
+ # 9. Bidirectional Word-Level Validation (Solution 24)
2095
+ # Compare our result word-by-word with raw model output
2096
+ # If we corrupted a word that the model got right, revert that word
2097
+ if raw_model_output and result != raw_model_output:
2098
+ result_words = result.split()
2099
+ raw_words = raw_model_output.split()
2100
+
2101
+ if len(result_words) == len(raw_words):
2102
+ corrected_words = []
2103
+ changed = False
2104
+
2105
+ for rw, raw_w in zip(result_words, raw_words):
2106
+ if rw != raw_w:
2107
+ rw_iv = self.vocab_manager.is_iv(rw)
2108
+ raw_iv = self.vocab_manager.is_iv(raw_w)
2109
+
2110
+ # Case 1: Our word is OOV but raw word is IV → take raw
2111
+ if not rw_iv and raw_iv:
2112
+ corrected_words.append(raw_w)
2113
+ changed = True
2114
+ # Case 2: Both IV but our word is further from input
2115
+ elif rw_iv and raw_iv:
2116
+ # Find corresponding input word
2117
+ input_words = preprocessed_text.split()
2118
+ idx = len(corrected_words)
2119
+ if idx < len(input_words):
2120
+ input_w = input_words[idx]
2121
+ rw_dist = Levenshtein.distance(input_w, rw)
2122
+ raw_dist = Levenshtein.distance(input_w, raw_w)
2123
+ # If raw is closer to input AND both are IV → prefer raw
2124
+ # (our pipeline likely introduced unnecessary change)
2125
+ if raw_dist < rw_dist:
2126
+ corrected_words.append(raw_w)
2127
+ changed = True
2128
+ else:
2129
+ corrected_words.append(rw)
2130
+ else:
2131
+ corrected_words.append(rw)
2132
+ else:
2133
+ corrected_words.append(rw)
2134
+ else:
2135
+ corrected_words.append(rw)
2136
+
2137
+ if changed:
2138
+ new_result = ' '.join(corrected_words)
2139
+ # Only accept if the new result doesn't increase OOV
2140
+ new_oov = self.vocab_manager.count_oov_words(new_result)
2141
+ old_oov = self.vocab_manager.count_oov_words(result)
2142
+ if new_oov <= old_oov:
2143
+ result = new_result
2144
+
2145
+ # 10. SAFETY NET: Compare with raw model output (Conservative)
2146
+ # Only switch to raw if raw is CLEARLY better
2147
+ if raw_model_output and raw_model_output != result:
2148
+ raw_oov = self.vocab_manager.count_oov_words(raw_model_output)
2149
+ our_oov = self.vocab_manager.count_oov_words(result)
2150
+
2151
+ # Case A: Raw all-IV, ours has OOV
2152
+ if raw_oov == 0 and our_oov > 0:
2153
+ is_valid, reason = self.validator.validate(original, raw_model_output, "mixed")
2154
+ if is_valid or reason == "space_leniency_accept":
2155
+ result = raw_model_output
2156
+
2157
+ # Case B: Both all-IV but raw is more similar to input
2158
+ # Catches BERT/postprocess damage (word substitutions up to 5 char distance)
2159
+ elif raw_oov == 0 and our_oov == 0:
2160
+ raw_dist = VocabularyManager.damerau_levenshtein_distance(original, raw_model_output)
2161
+ our_dist = VocabularyManager.damerau_levenshtein_distance(original, result)
2162
+ result_vs_raw_dist = VocabularyManager.damerau_levenshtein_distance(result, raw_model_output)
2163
+ # Threshold at 3 chars — covers single char edits and small substitutions
2164
+ # (widening to 5 caused regression by reverting valid hybrid corrections)
2165
+ if raw_dist < our_dist and result_vs_raw_dist <= 3:
2166
+ raw_valid, _ = self.validator.validate(original, raw_model_output, "mixed")
2167
+ if raw_valid:
2168
+ result = raw_model_output
2169
+
2170
+ # Case C: Word count differs — raw might have correct splitting
2171
+ # Catches: 'فيلق → في فيلق' (pipeline added word)
2172
+ # or 'بلاكبيرن روفرز → بلاكبيرن روفر' (pipeline lost word ending)
2173
+ elif raw_oov == 0:
2174
+ raw_wc = len(raw_model_output.split())
2175
+ our_wc = len(result.split())
2176
+ if raw_wc != our_wc:
2177
+ raw_dist = VocabularyManager.damerau_levenshtein_distance(original, raw_model_output)
2178
+ our_dist = VocabularyManager.damerau_levenshtein_distance(original, result)
2179
+ if raw_dist < our_dist:
2180
+ raw_valid, _ = self.validator.validate(original, raw_model_output, "mixed")
2181
+ if raw_valid:
2182
+ result = raw_model_output
2183
+
2184
  return result
2185
 
2186
+ # ═══════════════════════════════════════════════════════════════════════════════
2187
+ # PUBLIC API
2188
+ # ═══════════════════════════════════════════════════════════════════════════════
2189
+
2190
+ # Exported for use by benchmark.py and external consumers
2191
+ spell_checker = None # Will be initialized on first import with __main__ or by benchmark
2192
+
2193
+
2194
+ def initialize(use_contextual=True):
2195
+ """Initialize the spell checker. Call once before using."""
2196
+ global spell_checker
2197
+ spell_checker = ArabicSpellChecker(model, tokenizer, device, use_contextual=use_contextual)
2198
+ logger.info("Spell checker initialized")
2199
+ return spell_checker
2200
+
2201
+
2202
+ if __name__ == "__main__":
2203
+ sc = initialize(use_contextual=True)
2204
+
2205
+ # Quick demo
2206
+ test_cases = [
2207
+ "السلام عليكممم",
2208
+ "فيالمدرسه",
2209
+ "الطقص جميل اليومم",
2210
+ ]
2211
 
2212
+ print("\n" + "=" * 60)
2213
+ print("AraSpell Demo")
2214
+ print("=" * 60)
 
 
 
 
 
2215
 
2216
+ for text in test_cases:
2217
+ corrected = sc.correct(text)
2218
+ print(f"\n Input: {text}")
2219
+ print(f" Corrected: {corrected}")
2220
 
2221
+ print("\n" + "=" * 60)
2222
+ print("For full benchmark, run: python benchmark.py")
2223
+ print("=" * 60)
2224
 
Dockerfile CHANGED
@@ -13,19 +13,34 @@ COPY requirements.txt .
13
  RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu && \
14
  pip install --no-cache-dir -r requirements.txt
15
 
16
- # Pre-download the summarization model during build (network is available here)
17
  # At runtime, the container has NO outbound DNS, so models must be cached
 
 
18
  RUN python -c "\
19
  from transformers import MBartForConditionalGeneration, AutoTokenizer, AutoConfig; \
20
  import torch; \
21
  repo = 'bayan10/summarization-model'; \
22
- print('Downloading tokenizer...'); \
23
  AutoTokenizer.from_pretrained(repo); \
24
- print('Downloading config...'); \
25
  AutoConfig.from_pretrained(repo); \
26
- print('Downloading model (float16)...'); \
27
  MBartForConditionalGeneration.from_pretrained(repo, torch_dtype=torch.float16); \
28
- print('Model cached successfully!'); \
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  "
30
 
31
  # Copy application code
 
13
  RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu && \
14
  pip install --no-cache-dir -r requirements.txt
15
 
16
+ # Pre-download models during build (network is available here)
17
  # At runtime, the container has NO outbound DNS, so models must be cached
18
+
19
+ # 1. Summarization model (MBart, float16)
20
  RUN python -c "\
21
  from transformers import MBartForConditionalGeneration, AutoTokenizer, AutoConfig; \
22
  import torch; \
23
  repo = 'bayan10/summarization-model'; \
24
+ print('Downloading summarization tokenizer...'); \
25
  AutoTokenizer.from_pretrained(repo); \
26
+ print('Downloading summarization config...'); \
27
  AutoConfig.from_pretrained(repo); \
28
+ print('Downloading summarization model (float16)...'); \
29
  MBartForConditionalGeneration.from_pretrained(repo, torch_dtype=torch.float16); \
30
+ print('Summarization model cached!'); \
31
+ "
32
+
33
+ # 2. Spelling model (AraSpell — AraBERT encoder-decoder + checkpoint)
34
+ RUN python -c "\
35
+ from huggingface_hub import hf_hub_download; \
36
+ from transformers import AutoTokenizer, EncoderDecoderModel; \
37
+ print('Downloading AraSpell checkpoint...'); \
38
+ hf_hub_download(repo_id='bayan10/AraSpell-Model', filename='last_model.pt'); \
39
+ print('Downloading AraBERT tokenizer...'); \
40
+ AutoTokenizer.from_pretrained('aubmindlab/bert-base-arabertv02'); \
41
+ print('Downloading AraBERT encoder-decoder...'); \
42
+ EncoderDecoderModel.from_encoder_decoder_pretrained('aubmindlab/bert-base-arabertv02', 'aubmindlab/bert-base-arabertv02'); \
43
+ print('Spelling model cached!'); \
44
  "
45
 
46
  # Copy application code
gradio Spelling.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+
3
+ import re
4
+ from AraSpell import initialize
5
+
6
+ # تهيئة المصحح الإملائي وتحميل الموديل
7
+ sc = initialize(use_contextual=True)
8
+
9
+ import Levenshtein
10
+
11
+ def align_words(in_words, out_words):
12
+ n = len(in_words)
13
+ m = len(out_words)
14
+ dp = [[0] * (m + 1) for _ in range(n + 1)]
15
+
16
+ for i in range(1, n + 1):
17
+ dp[i][0] = dp[i-1][0] + len(in_words[i-1])
18
+ for j in range(1, m + 1):
19
+ dp[0][j] = dp[0][j-1] + len(out_words[j-1])
20
+
21
+ for i in range(1, n + 1):
22
+ for j in range(1, m + 1):
23
+ cost_replace = Levenshtein.distance(in_words[i-1], out_words[j-1])
24
+ dp[i][j] = min(
25
+ dp[i-1][j-1] + cost_replace,
26
+ dp[i-1][j] + len(in_words[i-1]),
27
+ dp[i][j-1] + len(out_words[j-1])
28
+ )
29
+
30
+ i, j = n, m
31
+ ops = []
32
+ while i > 0 or j > 0:
33
+ if i > 0 and j > 0:
34
+ cost_replace = Levenshtein.distance(in_words[i-1], out_words[j-1])
35
+ if dp[i][j] == dp[i-1][j-1] + cost_replace:
36
+ if cost_replace == 0:
37
+ ops.append(('equal', [in_words[i-1]], [out_words[j-1]]))
38
+ else:
39
+ ops.append(('replace', [in_words[i-1]], [out_words[j-1]]))
40
+ i -= 1
41
+ j -= 1
42
+ continue
43
+ if i > 0 and dp[i][j] == dp[i-1][j] + len(in_words[i-1]):
44
+ ops.append(('delete', [in_words[i-1]], []))
45
+ i -= 1
46
+ else:
47
+ ops.append(('insert', [], [out_words[j-1]]))
48
+ j -= 1
49
+
50
+ ops.reverse()
51
+
52
+ merged_ops = []
53
+ for op, in_w, out_w in ops:
54
+ if not merged_ops:
55
+ merged_ops.append([op, in_w, out_w])
56
+ continue
57
+
58
+ prev_op = merged_ops[-1][0]
59
+ if op != 'equal' and prev_op != 'equal' and not (op == 'replace' and prev_op == 'replace'):
60
+ merged_ops[-1][0] = 'replace'
61
+ merged_ops[-1][1].extend(in_w)
62
+ merged_ops[-1][2].extend(out_w)
63
+ else:
64
+ merged_ops.append([op, in_w, out_w])
65
+
66
+ return merged_ops
67
+
68
+ def generate_highlights(input_text):
69
+ if not input_text or not input_text.strip():
70
+ return [], {}
71
+
72
+ corrected_text = sc.correct(input_text)
73
+
74
+ in_words = input_text.split()
75
+ out_words = corrected_text.split()
76
+
77
+ ops = align_words(in_words, out_words)
78
+
79
+ highlight_list = []
80
+ suggestions_map = {}
81
+
82
+ idx = 0
83
+ for tag, in_w, out_w in ops:
84
+ if tag == 'equal':
85
+ for w in in_w:
86
+ highlight_list.append((w, None))
87
+ highlight_list.append((" ", None))
88
+ idx += 2
89
+ elif tag == 'replace' or tag == 'insert' or tag == 'delete':
90
+ in_phrase = " ".join(in_w) if in_w else "[ناقص]"
91
+ out_phrase = " ".join(out_w) if out_w else "(حذف الكلمة)"
92
+
93
+ highlight_list.append((in_phrase, " "))
94
+ sugs = [out_phrase]
95
+
96
+ if len(in_w) == 1 and len(out_w) == 1:
97
+ clean_w = re.sub(r'[^\w]', '', in_w[0])
98
+ try:
99
+ edit_cands = sc.edit_corrector.known(sc.edit_corrector.edits1(clean_w))
100
+ if edit_cands:
101
+ edit_cands = sorted(list(edit_cands), key=lambda x: sc.vocab_manager.get_frequency_rank(x))
102
+ for c in edit_cands:
103
+ if c not in sugs and len(sugs) < 3:
104
+ sugs.append(c)
105
+ except Exception:
106
+ pass
107
+
108
+ suggestions_map[idx] = sugs
109
+ highlight_list.append((" ", None))
110
+ idx += 2
111
+
112
+ if highlight_list and highlight_list[-1] == (" ", None):
113
+ highlight_list.pop()
114
+
115
+ return highlight_list, suggestions_map
116
+
117
+ # ==========================================
118
+ # تصميم واجهة المستخدم التفاعلية (Gradio Blocks)
119
+ # ==========================================
120
+ with gr.Blocks(theme=gr.themes.Soft(), css="""
121
+ .highlight-error { background-color: #ffcccc !important; border-radius: 4px; padding: 2px; }
122
+ .rtl-text { direction: rtl !important; text-align: right !important; }
123
+ """) as iface:
124
+
125
+ gr.Markdown("# 📝 AraSpell - المصحح الإملائي التفاعلي")
126
+ gr.Markdown("أدخل النص أدناه واضغط على **فحص النص**. سيقوم النظام بتلوين الأخطاء باللون الأحمر. **انقر على الكلمة الملونة** لتظهر لك خيارات التصحيح أسفلها!")
127
+
128
+ # متغيرات حالة (State) لحفظ البيانات خلف الكواليس
129
+ suggestions_state = gr.State({})
130
+ current_edit_index = gr.State(None)
131
+ highlight_list_state = gr.State([])
132
+
133
+ with gr.Row():
134
+ with gr.Column(scale=1):
135
+ input_box = gr.Textbox(lines=8, label="النص الأصلي", placeholder="أدخل النص العربي هنا...")
136
+ check_btn = gr.Button("🔍 فحص النص", variant="primary")
137
+
138
+ with gr.Column(scale=1):
139
+ output_highlights = gr.HighlightedText(
140
+ label="النتيجة (اضغط على الكلمات الملونة للتصحيح)",
141
+ combine_adjacent=False,
142
+ show_legend=False,
143
+ color_map={" ": "red"},
144
+ elem_classes="rtl-text"
145
+ )
146
+
147
+ # لوحة الاقتراحات (مخفية في البداية)
148
+ with gr.Group(visible=False) as suggestion_panel:
149
+ gr.Markdown("### 💡 اختر التصحيح المناسب:")
150
+ suggestion_radio = gr.Radio(choices=[], label="")
151
+ apply_btn = gr.Button("✅ تطبيق التصحيح", variant="secondary")
152
+
153
+ # 1. عند الضغط على فحص النص
154
+ def process_text(text):
155
+ h_list, s_map = generate_highlights(text)
156
+ # إرجاع: النص المظلل، قاموس الاقتراحات، قائمة التظليل (State)، وإخفاء لوحة الاقتراحات
157
+ return h_list, s_map, h_list, gr.update(visible=False)
158
+
159
+ check_btn.click(
160
+ fn=process_text,
161
+ inputs=[input_box],
162
+ outputs=[output_highlights, suggestions_state, highlight_list_state, suggestion_panel]
163
+ )
164
+
165
+ # 2. عند النقر على أي كلمة داخل النص المظلل
166
+ def on_highlight_click(evt: gr.SelectData, s_map):
167
+ index = evt.index
168
+ # معالجة مشكلة تحويل المفاتيح إلى نصوص (Strings) في Gradio State
169
+ if index in s_map:
170
+ choices = s_map[index]
171
+ elif str(index) in s_map:
172
+ choices = s_map[str(index)]
173
+ else:
174
+ # إخفاء اللوحة إذا ضغط على كلمة صحيحة
175
+ return gr.update(visible=False), gr.update(), None
176
+
177
+ # إظهار اللوحة وتحديث الخيارات
178
+ return gr.update(visible=True), gr.update(choices=choices, value=choices[0]), index
179
+
180
+ output_highlights.select(
181
+ fn=on_highlight_click,
182
+ inputs=[suggestions_state],
183
+ outputs=[suggestion_panel, suggestion_radio, current_edit_index]
184
+ )
185
+
186
+ # 3. عند اختيار اقتراح والضغط على "تطبيق"
187
+ def apply_correction(choice, edit_idx, h_list):
188
+ if edit_idx is not None and choice:
189
+ # تحديث الكلمة في قائمة التظليل (بدون إعادة تشغيل الموديل لتكون سريعة جداً)
190
+ if choice == "(حذف الكلمة)":
191
+ h_list[edit_idx] = ("", None)
192
+ else:
193
+ h_list[edit_idx] = (choice, None)
194
+
195
+ # إعادة بناء النص الجديد
196
+ new_text = "".join([t[0] for t in h_list])
197
+
198
+ # إرجاع: تحديث مربع الإدخال، التظليل الجديد، بقاء الاقتراحات كما هي، State الجديد، وإخفاء اللوحة
199
+ return new_text, h_list, gr.update(), h_list, gr.update(visible=False)
200
+
201
+ return gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
202
+
203
+ apply_btn.click(
204
+ fn=apply_correction,
205
+ inputs=[suggestion_radio, current_edit_index, highlight_list_state],
206
+ outputs=[input_box, output_highlights, suggestions_state, highlight_list_state, suggestion_panel]
207
+ )
208
+
209
+ if __name__ == "__main__":
210
+ iface.launch()
src/app.py CHANGED
@@ -153,7 +153,7 @@ def health_check():
153
  'mode': 'hf_spaces_local',
154
  'models': {
155
  'summarization': summarization_model is not None,
156
- 'spelling': False,
157
  'autocomplete': False,
158
  'grammar': False,
159
  'punctuation': False
@@ -223,6 +223,74 @@ def debug_models():
223
  }), 200
224
 
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  @app.route('/api/summarize', methods=['POST'])
227
  def summarize():
228
  """
 
153
  'mode': 'hf_spaces_local',
154
  'models': {
155
  'summarization': summarization_model is not None,
156
+ 'spelling': _spelling_available(),
157
  'autocomplete': False,
158
  'grammar': False,
159
  'punctuation': False
 
223
  }), 200
224
 
225
 
226
+ def _spelling_available():
227
+ """Check if spelling model is loaded (without triggering lazy load)."""
228
+ try:
229
+ from nlp.spelling.araspell_service import is_loaded
230
+ return is_loaded()
231
+ except Exception:
232
+ return False
233
+
234
+
235
+ @app.route('/api/spelling', methods=['POST'])
236
+ def spelling_correction():
237
+ """
238
+ Correct spelling in Arabic text.
239
+
240
+ Request JSON:
241
+ {
242
+ "text": "Arabic text with spelling errors"
243
+ }
244
+
245
+ Response JSON:
246
+ {
247
+ "original_text": "...",
248
+ "corrected_text": "...",
249
+ "status": "success"
250
+ }
251
+ """
252
+ try:
253
+ if not request.is_json:
254
+ return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400
255
+
256
+ data = request.get_json()
257
+ text = data.get('text', '').strip()
258
+
259
+ if not text:
260
+ return jsonify({'error': 'Text is required', 'status': 'error'}), 400
261
+
262
+ if len(text) > MAX_TEXT_LENGTH:
263
+ return jsonify({
264
+ 'error': f'Text too long. Maximum {MAX_TEXT_LENGTH} characters.',
265
+ 'status': 'error'
266
+ }), 400
267
+
268
+ logger.info(f"Spelling correction request: text_length={len(text)}")
269
+
270
+ from nlp.spelling.araspell_service import get_spelling_model
271
+ checker = get_spelling_model()
272
+ corrected = checker.correct(text)
273
+
274
+ return jsonify({
275
+ 'original_text': text,
276
+ 'corrected_text': corrected,
277
+ 'status': 'success'
278
+ }), 200
279
+
280
+ except RuntimeError as e:
281
+ logger.error(f"Spelling model error: {e}")
282
+ return jsonify({
283
+ 'error': f'Spelling model unavailable: {str(e)[:200]}',
284
+ 'status': 'error'
285
+ }), 503
286
+ except Exception as e:
287
+ logger.error(f"Spelling correction error: {e}")
288
+ return jsonify({
289
+ 'error': f'Spelling correction failed: {str(e)[:200]}',
290
+ 'status': 'error'
291
+ }), 500
292
+
293
+
294
  @app.route('/api/summarize', methods=['POST'])
295
  def summarize():
296
  """
src/nlp/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # AraSpell — Arabic Spelling Correction Module
src/nlp/spelling/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # AraSpell — Arabic Spelling Correction
src/nlp/spelling/araspell_rules.py ADDED
@@ -0,0 +1,1407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # POST PROCESSOR
32
+ # ═══════════════════════════════════════════════════════════════════════════════
33
+
34
+ class AraSpellPostProcessor:
35
+ """Arabic text post-processing techniques."""
36
+
37
+ ARABIC_HARAKAT = 'ًٌٍَُِّْ'
38
+ TATWEEL = 'ـ'
39
+ NORMALIZER_MAP = {
40
+ 'ﻹ': 'لإ', 'ﻷ': 'لأ', 'ﻵ': 'لآ', 'ﻻ': 'لا', 'ﷲ': 'الله'
41
+ }
42
+ ARABIC_CONSONANTS = set('بتثجحخدذرزسشصضطظعغفقكلمن')
43
+
44
+ # --- Basic Normalization ---
45
+
46
+ @staticmethod
47
+ def remove_harakat(text: str) -> str:
48
+ """Remove Arabic diacritics"""
49
+ return re.sub(r'[ً-ْ]', '', text)
50
+
51
+ @staticmethod
52
+ def remove_tatweel(text: str) -> str:
53
+ """Remove Arabic kashida/tatweel"""
54
+ return text.replace(AraSpellPostProcessor.TATWEEL, '')
55
+
56
+ @staticmethod
57
+ def normalize_special_chars(text: str) -> str:
58
+ """Normalize special Arabic ligatures"""
59
+ for old, new in AraSpellPostProcessor.NORMALIZER_MAP.items():
60
+ text = text.replace(old, new)
61
+ return text
62
+
63
+ # --- Core Functions ---
64
+
65
+ @staticmethod
66
+ def unified_collapse_repeated(text: str) -> str:
67
+ """
68
+ Collapse repeated characters.
69
+ Arabic: 3+ consecutive → 1 | Latin: 2+ consecutive → 1
70
+ """
71
+ text = re.sub(r"([\u0600-\u06FF])\1{2,}", r"\1", text)
72
+ text = re.sub(r"([a-zA-Z])\1+", r"\1", text)
73
+ return text
74
+
75
+ @staticmethod
76
+ def remove_duplicate_words(text: str) -> str:
77
+ """Remove consecutive duplicate words. e.g. كتاب كتاب → كتاب"""
78
+ words = text.split()
79
+ if len(words) < 2:
80
+ return text
81
+ result = [words[0]]
82
+ for i in range(1, len(words)):
83
+ if words[i] != words[i-1]:
84
+ result.append(words[i])
85
+ return ' '.join(result)
86
+
87
+ @staticmethod
88
+ def normalize_spaces(text: str) -> str:
89
+ """Normalize whitespace: multiple spaces, unicode spaces, punctuation spacing."""
90
+ text = re.sub(r' +', ' ', text)
91
+ text = text.replace('\u00A0', ' ')
92
+ text = text.replace('\u200B', '')
93
+ text = text.replace('\u200C', '')
94
+ text = text.replace('\u200D', '')
95
+ text = text.strip()
96
+ text = re.sub(r'\s*([،؛؟!.])\s*', r'\1 ', text)
97
+ text = text.strip()
98
+ return text
99
+
100
+ @staticmethod
101
+ def remove_word_repetition_with_wa(text: str) -> str:
102
+ """Remove word و word → word"""
103
+ words = text.split()
104
+ result = []
105
+ i = 0
106
+ while i < len(words):
107
+ if i + 2 < len(words) and words[i] == words[i+2] and words[i+1] == 'و':
108
+ result.append(words[i])
109
+ i += 3
110
+ else:
111
+ result.append(words[i])
112
+ i += 1
113
+ return ' '.join(result)
114
+
115
+ # --- Hamza & Ta Marbuta Handling ---
116
+
117
+ @staticmethod
118
+ def fix_hamza_conservative(text: str) -> str:
119
+ """Conservative Hamza normalization — only at word END, not middle."""
120
+ words = text.split()
121
+ result = []
122
+ for word in words:
123
+ if len(word) >= 3:
124
+ if word.endswith('أ'):
125
+ word = word[:-1] + 'ا'
126
+ if word.endswith('إ'):
127
+ word = word[:-1] + 'ا'
128
+ result.append(word)
129
+ return ' '.join(result)
130
+
131
+ @staticmethod
132
+ def fix_ha_ta_marbuta(text: str, vocab_manager=None) -> str:
133
+ """
134
+ Smart ه → ة fix at end of words.
135
+ Strategy: Only convert if the ة version is IV (in tokenizer vocab).
136
+ """
137
+ PROTECTED_ENDINGS = ['لله']
138
+ words = text.split()
139
+ result = []
140
+ for word in words:
141
+ if any(word.endswith(e) for e in PROTECTED_ENDINGS):
142
+ result.append(word)
143
+ continue
144
+ if len(word) >= 4 and word.endswith('ه'):
145
+ if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS:
146
+ candidate_with_ta = word[:-1] + 'ة'
147
+ if vocab_manager:
148
+ ta_iv = vocab_manager.is_iv(candidate_with_ta)
149
+ ha_iv = vocab_manager.is_iv(word)
150
+ if ta_iv:
151
+ result.append(candidate_with_ta)
152
+ continue
153
+ elif ha_iv:
154
+ result.append(word)
155
+ continue
156
+ else:
157
+ result.append(candidate_with_ta)
158
+ continue
159
+ result.append(word)
160
+ return ' '.join(result)
161
+
162
+ # --- Hallucination Removal ---
163
+
164
+ @staticmethod
165
+ def remove_hallucinations(text: str) -> str:
166
+ """Remove model hallucinations: duplicate words, trailing 'و' artifacts."""
167
+ words = text.split()
168
+ if not words:
169
+ return text
170
+ result = []
171
+ i = 0
172
+
173
+ def normalize_word(w: str) -> str:
174
+ w = w.replace('ال', '').replace('ة', 'ه')
175
+ w = re.sub(r'[أإآ]', 'ا', w)
176
+ return w
177
+
178
+ while i < len(words):
179
+ word = words[i]
180
+ if len(word) > 4 and word.endswith('و'):
181
+ prev_char = word[-2]
182
+ if prev_char in 'ةهاأإآء':
183
+ word = word[:-1]
184
+ if i + 1 < len(words):
185
+ next_word = words[i + 1]
186
+ if normalize_word(word) == normalize_word(next_word):
187
+ keep = next_word if next_word.startswith('ال') and not word.startswith('ال') else word
188
+ result.append(keep)
189
+ i += 2
190
+ continue
191
+ result.append(word)
192
+ i += 1
193
+ return ' '.join(result)
194
+
195
+ @staticmethod
196
+ def remove_hallucinated_prefix(text: str, original: str) -> str:
197
+ """Remove particles (و/في) added by model if not in original"""
198
+ if not original:
199
+ return text
200
+ if text.startswith('و ') and not original.startswith('و'):
201
+ rest = text[2:].strip()
202
+ if AraSpellPostProcessor.normalize_special_chars(rest) == AraSpellPostProcessor.normalize_special_chars(original):
203
+ return rest
204
+ return text
205
+
206
+ # --- Word Splitting & Merging ---
207
+
208
+ @staticmethod
209
+ def merge_separated_al(text: str) -> str:
210
+ """Merge 'ال' separated by space: ال + كتاب → الكتاب"""
211
+ return re.sub(r'\bال\s+(\w+)', r'ال\1', text)
212
+
213
+ @staticmethod
214
+ def join_fragments(text: str) -> str:
215
+ """Join short fragments with validation."""
216
+ words = text.split()
217
+ if len(words) < 2:
218
+ return text
219
+ STANDALONE_WORDS = {
220
+ 'من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى', 'حتى', 'منذ', 'خلال',
221
+ 'بعد', 'قبل', 'ب', 'ل', 'ك', 'و', 'أو', 'لا', 'ما', 'لم', 'لن',
222
+ 'هو', 'هي', 'هم', 'أن', 'إن', 'كل', 'كان', 'قد', 'قال', 'ذلك',
223
+ 'هذا', 'هذه', 'تلك', 'التي', 'الذي', 'التى', 'اللذي'
224
+ }
225
+ result = []
226
+ i = 0
227
+ while i < len(words):
228
+ word = words[i]
229
+ if i + 1 < len(words):
230
+ next_word = words[i + 1]
231
+ if word in STANDALONE_WORDS and next_word in STANDALONE_WORDS:
232
+ result.append(word)
233
+ i += 1
234
+ continue
235
+ if len(next_word) == 1:
236
+ result.append(word + next_word)
237
+ i += 2
238
+ continue
239
+ if len(word) >= 2 and len(next_word) >= 2 and word[-1] == next_word[0]:
240
+ if not (word in STANDALONE_WORDS and next_word in STANDALONE_WORDS):
241
+ result.append(word[:-1] + next_word)
242
+ i += 2
243
+ continue
244
+ if (2 <= len(word) <= 4 and
245
+ 1 <= len(next_word) <= 2 and
246
+ 3 <= len(word) + len(next_word) <= 7):
247
+ if not (word in STANDALONE_WORDS and next_word in STANDALONE_WORDS):
248
+ result.append(word + next_word)
249
+ i += 2
250
+ continue
251
+ result.append(word)
252
+ i += 1
253
+ return ' '.join(result)
254
+
255
+ # --- Main Pipelines ---
256
+
257
+ @staticmethod
258
+ def full_postprocess(text: str, original: str = "", vocab_manager=None) -> str:
259
+ """Apply all post-processing steps."""
260
+ if original:
261
+ text = AraSpellPostProcessor.remove_hallucinated_prefix(text, original)
262
+ text = AraSpellPostProcessor.normalize_special_chars(text)
263
+ text = AraSpellPostProcessor.remove_hallucinations(text)
264
+ text = AraSpellPostProcessor.unified_collapse_repeated(text)
265
+ text = AraSpellPostProcessor.fix_hamza_conservative(text)
266
+ text = AraSpellPostProcessor.fix_ha_ta_marbuta(text, vocab_manager=vocab_manager)
267
+ text = AraSpellPostProcessor.remove_word_repetition_with_wa(text)
268
+ text = AraSpellPostProcessor.remove_duplicate_words(text)
269
+ text = AraSpellPostProcessor.normalize_spaces(text)
270
+ return text
271
+
272
+
273
+ # ─────────────────────────────────────────────────────────────────────────────
274
+ # ERROR CLASSIFIER
275
+ # ─────────────────────────────────────────────────────────────────────────────
276
+
277
+ class ErrorClassifier:
278
+ """Classify type of spelling error"""
279
+
280
+ NON_ARABIC_KEYBOARD = set('پگچژکەڕڤڵڎےۀۃھیټډڼڑ')
281
+
282
+ @staticmethod
283
+ def has_char_substitution(text: str) -> bool:
284
+ return any(c in ErrorClassifier.NON_ARABIC_KEYBOARD for c in text)
285
+
286
+ @staticmethod
287
+ def has_char_repetition(text: str, threshold: int = 3) -> bool:
288
+ return bool(re.search(r"(.)\1{" + str(threshold - 1) + ",}", text))
289
+
290
+ @staticmethod
291
+ def has_word_merge(text: str, max_word_len: int = 8) -> bool:
292
+ words = text.split()
293
+ if any(len(w) > max_word_len for w in words):
294
+ return True
295
+ if len(words) == 1 and len(text) > 6:
296
+ return True
297
+ return False
298
+
299
+ @staticmethod
300
+ def classify(text: str) -> ErrorType:
301
+ has_rep = ErrorClassifier.has_char_repetition(text)
302
+ has_merge = ErrorClassifier.has_word_merge(text)
303
+ has_sub = ErrorClassifier.has_char_substitution(text)
304
+ error_count = sum([has_rep, has_merge, has_sub])
305
+ if error_count >= 2:
306
+ return ErrorType.MIXED
307
+ elif has_sub:
308
+ return ErrorType.CHAR_SUBSTITUTION
309
+ elif has_rep:
310
+ return ErrorType.CHAR_REPETITION
311
+ elif has_merge:
312
+ return ErrorType.WORD_MERGE
313
+ else:
314
+ return ErrorType.CLEAN
315
+
316
+
317
+ # ═══════════════════════════════════════════════════════════════════════════════
318
+ # RULES-BASED CORRECTOR
319
+ # ═══════════════════════════════════════════════════════════════════════════════
320
+
321
+ class RulesBasedCorrector:
322
+ """Rules-based correction with keyboard proximity mapping."""
323
+
324
+ SUBSTITUTION_MAP = {
325
+ 'ک': 'ك', 'ی': 'ي', 'ے': 'ي',
326
+ 'پ': 'ب', 'چ': 'ج', 'ژ': 'ز',
327
+ 'گ': 'ك', 'ڤ': 'ف', 'ڵ': 'ل',
328
+ 'ڕ': 'ر', 'ڎ': 'د', 'ڼ': 'ن',
329
+ 'ټ': 'ت', 'ډ': 'د', 'ړ': 'ر',
330
+ 'ۀ': 'ه', 'ۃ': 'ة', 'ھ': 'ه',
331
+ 'ە': 'ه', 'ڑ': 'ر'
332
+ }
333
+
334
+ PREPOSITIONS = {
335
+ 'من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى',
336
+ 'حتى', 'منذ', 'خلال', 'بعد', 'قبل',
337
+ 'ب', 'ل', 'ك', 'لل'
338
+ }
339
+
340
+ KEYBOARD_NEIGHBORS = {
341
+ 'ض': ['ص', 'ق'], 'ص': ['ض', 'ث', 'ق'], 'ث': ['ص', 'ق'],
342
+ 'ق': ['ض', 'ص', 'ث', 'ف', 'غ'], 'ف': ['ق', 'غ', 'ع', 'ب'],
343
+ 'غ': ['ق', 'ف', 'ع', 'ه'], 'ع': ['ف', 'غ', 'ه', 'خ'],
344
+ 'ه': ['غ', 'ع', 'خ', 'ح'], 'خ': ['ع', 'ه', 'ح', 'ج'],
345
+ 'ح': ['ه', 'خ', 'ج'], 'ج': ['خ', 'ح', 'د'],
346
+ 'د': ['ج', 'ذ'], 'ذ': ['د'],
347
+ 'ش': ['س', 'ي', 'ئ'], 'س': ['ش', 'ي', 'ب'],
348
+ 'ي': ['ش', 'س', 'ب', 'ت'], 'ب': ['ي', 'س', 'ف', 'ل', 'ن'],
349
+ 'ل': ['ب', 'ا', 'ن', 'م'], 'ا': ['ل', 'ت', 'م'],
350
+ 'ت': ['ي', 'ا', 'ن'], 'ن': ['ب', 'ل', 'ت', 'م', 'ك'],
351
+ 'م': ['ل', 'ا', 'ن', 'ك'], 'ك': ['ن', 'م', 'ط'],
352
+ 'ط': ['ك', 'ظ'], 'ظ': ['ط'],
353
+ 'ئ': ['ش', 'ء', 'ر'], 'ء': ['ئ', 'ؤ'], 'ؤ': ['ء', 'ر'],
354
+ 'ر': ['ئ', 'ؤ', 'لا', 'ى', 'ز'], 'لا': ['ر', 'ى'],
355
+ 'ى': ['ر', 'لا', 'ة', 'ز'], 'ة': ['ى', 'و', 'ز'],
356
+ 'و': ['ة', 'ز'], 'ز': ['ر', 'ى', 'ة', 'و'],
357
+ 'أ': ['ا', 'إ', 'آ'], 'إ': ['ا', 'أ'], 'آ': ['ا', 'أ'],
358
+ }
359
+
360
+ @staticmethod
361
+ def is_keyboard_neighbor(char1: str, char2: str) -> bool:
362
+ neighbors = RulesBasedCorrector.KEYBOARD_NEIGHBORS.get(char1, [])
363
+ return char2 in neighbors
364
+
365
+ @staticmethod
366
+ def fix_char_substitution(text: str) -> str:
367
+ for old, new in RulesBasedCorrector.SUBSTITUTION_MAP.items():
368
+ text = text.replace(old, new)
369
+ return text
370
+
371
+ @staticmethod
372
+ def fix_char_repetition(text: str) -> str:
373
+ text = re.sub(r'([^\d\s])\1{2,}', r'\1', text)
374
+ return text
375
+
376
+ @staticmethod
377
+ def advanced_heuristic_repair(text: str) -> str:
378
+ text = RulesBasedCorrector.fix_char_substitution(text)
379
+ text = RulesBasedCorrector.fix_char_repetition(text)
380
+ words = text.split()
381
+ processed_words = []
382
+ for word in words:
383
+ processed_words.append(RulesBasedCorrector._recursive_split(word))
384
+ return ' '.join(processed_words)
385
+
386
+ @staticmethod
387
+ def _recursive_split(word: str) -> str:
388
+ if len(word) < 4:
389
+ return word
390
+ separables = sorted(['من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى', 'حتى', 'منذ', 'خلال', 'بعد', 'قبل'], key=len, reverse=True)
391
+ for sep in separables:
392
+ if word == sep:
393
+ return word
394
+ if word.startswith(sep):
395
+ remainder = word[len(sep):]
396
+ if len(remainder) >= 3:
397
+ return sep + " " + RulesBasedCorrector._recursive_split(remainder)
398
+ if word.startswith('يا') and len(word) > 4:
399
+ return 'يا ' + RulesBasedCorrector._recursive_split(word[2:])
400
+ return word
401
+
402
+
403
+ # ═══════════════════════════════════════════════════════════════════════════════
404
+ # OUTPUT VALIDATOR (Hallucination Prevention)
405
+ # ═══════════════════════════════════════════════════════════════════════════════
406
+
407
+ class OutputValidator:
408
+ """Validate model outputs to prevent hallucinations"""
409
+
410
+ @staticmethod
411
+ def calculate_edit_distance(s1: str, s2: str) -> int:
412
+ return Levenshtein.distance(s1, s2)
413
+
414
+ @staticmethod
415
+ def check_character_preservation(original: str, corrected: str) -> Tuple[bool, str]:
416
+ chars_original = set(original)
417
+ chars_corrected = set(corrected)
418
+ if not chars_original:
419
+ return True, "valid"
420
+ intersection = chars_original & chars_corrected
421
+ union = chars_original | chars_corrected
422
+ jaccard = len(intersection) / len(union) if union else 0
423
+ if jaccard < 0.35:
424
+ return False, "low_character_similarity"
425
+ return True, "valid"
426
+
427
+ @staticmethod
428
+ def check_word_count(original: str, corrected: str) -> Tuple[bool, str]:
429
+ len_orig = len(original.split())
430
+ len_corr = len(corrected.split())
431
+ if len_orig == 1:
432
+ if len_corr <= 3:
433
+ return True, "valid"
434
+ if len(original) > 12 and len_corr <= 6:
435
+ return True, "valid"
436
+ ratio = len_corr / len_orig if len_orig > 0 else 0
437
+ if ratio > 2.0 or ratio < 0.5:
438
+ return False, "word_count_mismatch"
439
+ return True, "valid"
440
+
441
+ def validate(self, original: str, corrected: str, error_type: str) -> Tuple[bool, str]:
442
+ if not corrected or not corrected.strip():
443
+ return False, "empty_output"
444
+ original_no_space = original.replace(' ', '').replace('\u200c', '')
445
+ corrected_no_space = corrected.replace(' ', '').replace('\u200c', '')
446
+ if original_no_space == corrected_no_space:
447
+ return True, "space_leniency_accept"
448
+ len_orig = len(original)
449
+ len_corr = len(corrected)
450
+ if len_corr > len_orig * 2.5:
451
+ return False, "too_long"
452
+ if len_corr < len_orig * 0.5:
453
+ if error_type == ErrorType.CHAR_REPETITION:
454
+ pass
455
+ else:
456
+ return False, "too_short"
457
+ is_valid_count, reason = self.check_word_count(original, corrected)
458
+ if not is_valid_count:
459
+ return False, reason
460
+ is_valid_chars, reason = self.check_character_preservation(original, corrected)
461
+ if not is_valid_chars:
462
+ return False, reason
463
+ return True, "valid"
464
+
465
+
466
+ # ═══════════════════════════════════════════════════════════════════════════════
467
+ # VOCABULARY MANAGER
468
+ # ══════════════════════════════════════════════════════════════════════════════��
469
+
470
+ class VocabularyManager:
471
+ """Centralized vocabulary management for OOV/IV detection."""
472
+
473
+ HAMZA_VARIANTS = {'أ', 'إ', 'آ', 'ء', 'ؤ', 'ئ', 'ا'}
474
+ ALEF_NORMALIZED = 'ا'
475
+ TA_MARBUTA = 'ة'
476
+ HA = 'ه'
477
+ YA_VARIANTS = {'ي', 'ى'}
478
+ YA_NORMALIZED = 'ي'
479
+
480
+ def __init__(self, tokenizer):
481
+ self.tokenizer = tokenizer
482
+ self.vocab = {
483
+ w for w in tokenizer.get_vocab().keys()
484
+ if w.isalpha() and not w.startswith('##') and len(w) > 1
485
+ }
486
+ self.vocab_rank = {w: i for w, i in tokenizer.get_vocab().items()}
487
+ self.normalized_vocab = {self.normalize_for_comparison(w): w for w in self.vocab}
488
+ logger.info(f"VocabularyManager initialized: {len(self.vocab)} words")
489
+
490
+ @classmethod
491
+ def normalize_for_comparison(cls, word: str) -> str:
492
+ result = []
493
+ for i, char in enumerate(word):
494
+ if char in cls.HAMZA_VARIANTS:
495
+ result.append(cls.ALEF_NORMALIZED)
496
+ elif char == cls.TA_MARBUTA and i == len(word) - 1:
497
+ result.append(cls.HA)
498
+ elif char in cls.YA_VARIANTS:
499
+ result.append(cls.YA_NORMALIZED)
500
+ else:
501
+ result.append(char)
502
+ return ''.join(result)
503
+
504
+ def is_iv(self, word: str) -> bool:
505
+ clean = re.sub(r'[^\w]', '', word)
506
+ if not clean:
507
+ return True
508
+ if clean in self.vocab:
509
+ return True
510
+ normalized = self.normalize_for_comparison(clean)
511
+ if normalized in self.normalized_vocab:
512
+ return True
513
+ return False
514
+
515
+ def is_oov(self, word: str) -> bool:
516
+ return not self.is_iv(word)
517
+
518
+ def get_frequency_rank(self, word: str) -> int:
519
+ clean = re.sub(r'[^\w]', '', word)
520
+ return self.vocab_rank.get(clean, 999999)
521
+
522
+ def all_words_iv(self, text: str) -> bool:
523
+ words = text.split()
524
+ return all(self.is_iv(w) for w in words)
525
+
526
+ def count_oov_words(self, text: str) -> int:
527
+ words = text.split()
528
+ return sum(1 for w in words if self.is_oov(w))
529
+
530
+ def get_oov_words(self, text: str) -> List[str]:
531
+ words = text.split()
532
+ return [w for w in words if self.is_oov(w)]
533
+
534
+ def words_are_equivalent(self, word1: str, word2: str) -> bool:
535
+ norm1 = self.normalize_for_comparison(word1)
536
+ norm2 = self.normalize_for_comparison(word2)
537
+ return norm1 == norm2
538
+
539
+ @staticmethod
540
+ def damerau_levenshtein_distance(s1: str, s2: str) -> int:
541
+ return jellyfish.damerau_levenshtein_distance(s1, s2)
542
+
543
+ def calculate_similarity(self, original: str, corrected: str) -> float:
544
+ dist = self.damerau_levenshtein_distance(original, corrected)
545
+ max_len = max(len(original), len(corrected), 1)
546
+ return 1.0 - (dist / max_len)
547
+
548
+
549
+ # ═══════════════════════════════════════════════════════════════════════════════
550
+ # WORD ALIGNER
551
+ # ═══════════════════════════════════════════════════════════════════════════════
552
+
553
+ class WordAligner:
554
+ """Aligns input and output words to create hybrid corrections."""
555
+
556
+ def __init__(self, vocab_manager):
557
+ self.vocab = vocab_manager
558
+
559
+ def align_words(self, input_text: str, output_text: str) -> str:
560
+ input_words = input_text.split()
561
+ output_words = output_text.split()
562
+ if abs(len(input_words) - len(output_words)) > 2:
563
+ input_oov = self.vocab.count_oov_words(input_text)
564
+ output_oov = self.vocab.count_oov_words(output_text)
565
+ return output_text if output_oov < input_oov else input_text
566
+ result = []
567
+ min_len = min(len(input_words), len(output_words))
568
+ for i in range(min_len):
569
+ in_word = input_words[i]
570
+ out_word = output_words[i]
571
+ best_word = self._select_best_word(in_word, out_word)
572
+ result.append(best_word)
573
+ if len(output_words) > min_len:
574
+ result.extend(output_words[min_len:])
575
+ elif len(input_words) > min_len:
576
+ for w in input_words[min_len:]:
577
+ if self.vocab.is_iv(w):
578
+ result.append(w)
579
+ return ' '.join(result)
580
+
581
+ def _select_best_word(self, input_word: str, output_word: str) -> str:
582
+ if input_word == output_word:
583
+ return input_word
584
+ in_iv = self.vocab.is_iv(input_word)
585
+ out_iv = self.vocab.is_iv(output_word)
586
+ if not in_iv and out_iv:
587
+ return output_word
588
+ if in_iv and not out_iv:
589
+ return input_word
590
+ if in_iv and out_iv:
591
+ return input_word
592
+ if len(input_word) == len(output_word) and len(input_word) >= 3:
593
+ for i in range(len(input_word)):
594
+ if input_word[i] != output_word[i]:
595
+ hybrid = input_word[:i] + output_word[i] + input_word[i+1:]
596
+ if self.vocab.is_iv(hybrid):
597
+ return hybrid
598
+ hybrid2 = output_word[:i] + input_word[i] + output_word[i+1:]
599
+ if self.vocab.is_iv(hybrid2):
600
+ return hybrid2
601
+ return output_word
602
+
603
+
604
+ # ═══════════════════════════════════════════════════════════════════════════════
605
+ # SPLIT/MERGE SPECIALIST
606
+ # ═══════════════════════════════════════════════════════════════════════════════
607
+
608
+ class SplitMergeSpecialist:
609
+ """Handles word splitting and merging with vocabulary validation."""
610
+
611
+ SEPARABLE_PREFIXES = [
612
+ 'من', 'في', 'على', 'عن', 'مع', 'إلى', 'الى', 'حتى', 'منذ', 'خلال',
613
+ 'بعد', 'قبل', 'بين', 'حول', 'تحت', 'فوق', 'أمام', 'وراء', 'دون',
614
+ 'أن', 'لن', 'لم', 'قد', 'سوف', 'كي', 'إذا', 'لو', 'مثل', 'غير',
615
+ 'يا',
616
+ ]
617
+
618
+ PROTECTED_WORDS = {
619
+ 'في', 'من', 'على', 'عن', 'مع', 'إلى', 'الى', 'ان', 'أن', 'لا', 'ما', 'هو', 'هي',
620
+ 'لم', 'لن', 'قد', 'كل', 'كان', 'ذلك', 'هذا', 'هذه', 'التي', 'الذي', 'بين',
621
+ }
622
+
623
+ ATTACHED_PREFIXES = [
624
+ 'وال', 'بال', 'فال', 'كال', 'لل',
625
+ 'وب', 'وف', 'ول', 'وك', 'وم', 'ون',
626
+ 'فب', 'فل', 'فك', 'فم',
627
+ ]
628
+
629
+ PRONOUN_SUFFIXES = {'كم', 'هم', 'ها', 'هن', 'كن', 'نا', 'هما', 'كما', 'تم', 'تن'}
630
+
631
+ def __init__(self, vocab_manager):
632
+ self.vocab = vocab_manager
633
+ self.separable_prefixes = sorted(
634
+ self.SEPARABLE_PREFIXES, key=len, reverse=True
635
+ )
636
+
637
+ def split_word(self, word: str) -> str:
638
+ if len(word) < 5:
639
+ return word
640
+ if self.vocab.is_iv(word):
641
+ return word
642
+ if word in self.PROTECTED_WORDS:
643
+ return word
644
+ for prefix in self.ATTACHED_PREFIXES:
645
+ if word.startswith(prefix):
646
+ remainder = word[len(prefix):]
647
+ if self.vocab.is_iv(remainder):
648
+ return word
649
+ if prefix.endswith('ال') and self.vocab.is_iv(remainder):
650
+ return word
651
+ for prefix in self.separable_prefixes:
652
+ if word.startswith(prefix) and len(word) > len(prefix) + 2:
653
+ remainder = word[len(prefix):]
654
+ if self.vocab.is_iv(remainder):
655
+ return f"{prefix} {remainder}"
656
+ for i in range(3, len(word) - 2):
657
+ left = word[:i]
658
+ right = word[i:]
659
+ if self.vocab.is_iv(left) and self.vocab.is_iv(right):
660
+ return f"{left} {right}"
661
+ return word
662
+
663
+ def merge_fragments(self, text: str) -> str:
664
+ words = text.split()
665
+ if len(words) < 2:
666
+ return text
667
+ result = []
668
+ i = 0
669
+ while i < len(words):
670
+ word = words[i]
671
+ if i + 1 < len(words):
672
+ next_word = words[i + 1]
673
+ merged = word + next_word
674
+ if len(next_word) == 1 and next_word in 'ةهاي':
675
+ if self.vocab.is_iv(merged):
676
+ result.append(merged)
677
+ i += 2
678
+ continue
679
+ if word == 'ال' and len(next_word) >= 2:
680
+ if self.vocab.is_iv(merged):
681
+ result.append(merged)
682
+ i += 2
683
+ continue
684
+ if self.vocab.is_oov(word) and self.vocab.is_oov(next_word):
685
+ if self.vocab.is_iv(merged):
686
+ result.append(merged)
687
+ i += 2
688
+ continue
689
+ if len(word) <= 2 and self.vocab.is_oov(word):
690
+ if self.vocab.is_iv(merged):
691
+ result.append(merged)
692
+ i += 2
693
+ continue
694
+ if next_word in self.PRONOUN_SUFFIXES:
695
+ if self.vocab.is_iv(merged) and not self.vocab.is_iv(word):
696
+ result.append(merged)
697
+ i += 2
698
+ continue
699
+ if len(word) <= 3 and len(next_word) <= 3:
700
+ if len(merged) >= 5 and self.vocab.is_iv(merged):
701
+ result.append(merged)
702
+ i += 2
703
+ continue
704
+ result.append(word)
705
+ i += 1
706
+ return ' '.join(result)
707
+
708
+ def process_text(self, text: str) -> str:
709
+ text = self.merge_fragments(text)
710
+ words = text.split()
711
+ processed = []
712
+ for word in words:
713
+ if self.vocab.is_oov(word) and len(word) >= 4:
714
+ split_result = self.split_word(word)
715
+ processed.append(split_result)
716
+ else:
717
+ processed.append(word)
718
+ return ' '.join(processed)
719
+
720
+
721
+ # ═══════════════════════════════════════════════════════════════════════════════
722
+ # EDIT DISTANCE CORRECTOR
723
+ # ═══════════════════════════════════════════════════════════════════════════════
724
+
725
+ class EditDistanceCorrector:
726
+ """Generates candidates based on Levenshtein distance."""
727
+
728
+ def __init__(self, tokenizer):
729
+ self.tokenizer = tokenizer
730
+ self.vocab = {
731
+ w for w in tokenizer.get_vocab().keys()
732
+ if w.isalpha() and not w.startswith('##') and len(w) > 1
733
+ }
734
+ self.vocab_rank = {w: i for w, i in tokenizer.get_vocab().items()}
735
+
736
+ def edits1(self, word):
737
+ letters = 'أابتثجحخدذرزسشصضطظعغفقكلمنهويءآىةئؤ'
738
+ splits = [(word[:i], word[i:]) for i in range(len(word) + 1)]
739
+ deletes = [L + R[1:] for L, R in splits if R]
740
+ transposes = [L + R[1] + R[0] + R[2:] for L, R in splits if len(R)>1]
741
+ replaces = [L + c + R[1:] for L, R in splits if R for c in letters]
742
+ inserts = [L + c + R for L, R in splits for c in letters]
743
+ return set(deletes + transposes + replaces + inserts)
744
+
745
+ def edits2(self, word):
746
+ return (e2 for e1 in self.edits1(word) for e2 in self.edits1(e1))
747
+
748
+ def known(self, words):
749
+ return set(w for w in words if w in self.vocab)
750
+
751
+ def generate_candidate(self, text: str) -> str:
752
+ words = text.split()
753
+ corrected_words = []
754
+ for word in words:
755
+ clean_word = re.sub(r'[^\w]', '', word)
756
+ if clean_word in self.vocab:
757
+ corrected_words.append(word)
758
+ continue
759
+ candidates = self.known(self.edits1(clean_word))
760
+ if not candidates:
761
+ if len(clean_word) < 7:
762
+ candidates = self.known(self.edits2(clean_word))
763
+ if candidates:
764
+ best_candidate = min(candidates, key=lambda w: self.vocab_rank.get(w, 999999))
765
+ corrected_words.append(best_candidate)
766
+ else:
767
+ corrected_words.append(word)
768
+ return ' '.join(corrected_words)
769
+
770
+
771
+ # ═══════════════════════════════════════════════════════════════════════════════
772
+ # CONTEXTUAL CORRECTOR (MLM-based) — Optional, disabled by default to save RAM
773
+ # ═══════════════════════════════════════════════════════════════════════════════
774
+
775
+ class ContextualCorrector:
776
+ """MLM-based contextual correction for confusion pairs"""
777
+
778
+ CONFUSION_PAIRS = [
779
+ ('ض', 'ظ'), ('ذ', 'ز'), ('ث', 'س'), ('ص', 'س'),
780
+ ('ط', 'ت'), ('ق', 'ك'), ('ه', 'ة'), ('ا', 'ى'),
781
+ ('ت', 'د'), ('د', 'ض'), ('ك', 'ق'), ('غ', 'ق'),
782
+ ('ج', 'ش'), ('س', 'ز'), ('ف', 'ب'), ('و', 'و'),
783
+ ('ؤ', 'و'), ('ئ', 'ي'), ('ء', 'أ'), ('إ', 'أ'),
784
+ ]
785
+
786
+ def __init__(self, model_name: str = 'aubmindlab/bert-base-arabertv02', cache_size: int = 10000):
787
+ from transformers import AutoTokenizer, AutoModelForMaskedLM
788
+
789
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name)
790
+ self.model = AutoModelForMaskedLM.from_pretrained(model_name)
791
+ self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
792
+ self.model = self.model.to(self.device)
793
+ self.model.eval()
794
+ self.confusion_map = self._build_confusion_map()
795
+ self.cache_hits = 0
796
+ self.cache_misses = 0
797
+ self._score_cache = {}
798
+ self.cache_size = cache_size
799
+ self.vocab = self.tokenizer.get_vocab()
800
+
801
+ def _build_confusion_map(self):
802
+ confusion_map = {}
803
+ for char1, char2 in self.CONFUSION_PAIRS:
804
+ if char1 not in confusion_map:
805
+ confusion_map[char1] = []
806
+ if char2 not in confusion_map:
807
+ confusion_map[char2] = []
808
+ confusion_map[char1].append(char2)
809
+ confusion_map[char2].append(char1)
810
+ return confusion_map
811
+
812
+ def get_confusable_chars(self, char: str) -> List[str]:
813
+ return self.confusion_map.get(char, [])
814
+
815
+ def generate_candidates(self, word: str) -> List[str]:
816
+ candidates = [word]
817
+ for i, char in enumerate(word):
818
+ confusables = self.get_confusable_chars(char)
819
+ for conf_char in confusables:
820
+ candidate = word[:i] + conf_char + word[i+1:]
821
+ if candidate not in candidates:
822
+ candidates.append(candidate)
823
+ for i in range(len(word) - 1):
824
+ if word[i] == word[i+1]:
825
+ candidate = word[:i] + word[i+1:]
826
+ if candidate not in candidates:
827
+ candidates.append(candidate)
828
+ COMMON_CHARS = 'ابتثجحخدذرزسشصضطظعغفقكلمنهويأإآءئؤةى'
829
+ for i in range(len(word) + 1):
830
+ for char in COMMON_CHARS:
831
+ candidate = word[:i] + char + word[i:]
832
+ if candidate in self.vocab and candidate not in candidates:
833
+ candidates.append(candidate)
834
+ if len(word) < 7:
835
+ for i in range(len(word)):
836
+ for char in COMMON_CHARS:
837
+ if char != word[i]:
838
+ candidate = word[:i] + char + word[i+1:]
839
+ if candidate in self.vocab and candidate not in candidates:
840
+ candidates.append(candidate)
841
+ for i in range(len(word)):
842
+ candidate = word[:i] + word[i+1:]
843
+ if len(candidate) > 1:
844
+ if candidate in self.vocab and candidate not in candidates:
845
+ candidates.append(candidate)
846
+ return candidates
847
+
848
+ def score_with_mlm(self, text: str, position: int, word: str) -> float:
849
+ cache_key = f"{text}|{position}|{word}"
850
+ if cache_key in self._score_cache:
851
+ self.cache_hits += 1
852
+ return self._score_cache[cache_key]
853
+ self.cache_misses += 1
854
+ words = text.split()
855
+ if position >= len(words):
856
+ return 0.0
857
+ masked_words = words.copy()
858
+ masked_words[position] = '[MASK]'
859
+ masked_text = ' '.join(masked_words)
860
+ inputs = self.tokenizer(masked_text, return_tensors='pt', padding=True, truncation=True)
861
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
862
+ with torch.no_grad():
863
+ outputs = self.model(**inputs)
864
+ predictions = outputs.logits
865
+ mask_token_index = (inputs['input_ids'] == self.tokenizer.mask_token_id).nonzero(as_tuple=True)[1]
866
+ if len(mask_token_index) == 0:
867
+ return 0.0
868
+ mask_token_logits = predictions[0, mask_token_index[0], :]
869
+ probs = torch.softmax(mask_token_logits, dim=0)
870
+ word_tokens = self.tokenizer.encode(word, add_special_tokens=False)
871
+ if not word_tokens:
872
+ return 0.0
873
+ word_token_id = word_tokens[0]
874
+ score = probs[word_token_id].item()
875
+ if len(self._score_cache) >= self.cache_size:
876
+ self._score_cache.pop(next(iter(self._score_cache)))
877
+ self._score_cache[cache_key] = score
878
+ return score
879
+
880
+ def score_candidates_batch(self, text: str, position: int, candidates: List[str]) -> dict:
881
+ scores = {}
882
+ for candidate in candidates:
883
+ scores[candidate] = self.score_with_mlm(text, position, candidate)
884
+ return scores
885
+
886
+ def predict_masked_token(self, text: str, position: int, top_k: int = 5) -> List[Tuple[str, float]]:
887
+ words = text.split()
888
+ if position >= len(words):
889
+ return []
890
+ masked_words = words.copy()
891
+ masked_words[position] = '[MASK]'
892
+ masked_text = ' '.join(masked_words)
893
+ inputs = self.tokenizer(masked_text, return_tensors='pt', padding=True, truncation=True).to(self.device)
894
+ with torch.no_grad():
895
+ outputs = self.model(**inputs)
896
+ predictions = outputs.logits
897
+ mask_token_index = (inputs['input_ids'] == self.tokenizer.mask_token_id).nonzero(as_tuple=True)[1]
898
+ if len(mask_token_index) == 0:
899
+ return []
900
+ mask_token_logits = predictions[0, mask_token_index[0], :]
901
+ probs = torch.softmax(mask_token_logits, dim=0)
902
+ top_k_weights, top_k_indices = torch.topk(probs, top_k, sorted=True)
903
+ results = []
904
+ for i in range(top_k):
905
+ token_id = top_k_indices[i].item()
906
+ score = top_k_weights[i].item()
907
+ token = self.tokenizer.decode([token_id]).strip()
908
+ if not token.startswith("##") and token not in self.tokenizer.all_special_tokens:
909
+ results.append((token, score))
910
+ return results
911
+
912
+ def refine_sentence_with_mask(self, text: str, threshold: float = 0.001, vocab_manager=None, raw_model_output=None) -> str:
913
+ words = text.split()
914
+ refined_words = words.copy()
915
+ raw_words = raw_model_output.split() if raw_model_output else []
916
+ for i, word in enumerate(words):
917
+ if vocab_manager and vocab_manager.is_iv(word):
918
+ continue
919
+ if i < len(raw_words) and word == raw_words[i]:
920
+ continue
921
+ if len(word) <= 2:
922
+ continue
923
+ current_score = self.score_with_mlm(text, i, word)
924
+ if current_score > threshold:
925
+ continue
926
+ predictions = self.predict_masked_token(text, i, top_k=10)
927
+ for pred_word, pred_score in predictions:
928
+ if pred_word == word:
929
+ continue
930
+ if abs(len(pred_word) - len(word)) > 1:
931
+ continue
932
+ dist = Levenshtein.distance(word, pred_word)
933
+ max_len = max(len(word), len(pred_word))
934
+ similarity = 1.0 - (dist / max_len)
935
+ if similarity < 0.90:
936
+ continue
937
+ if vocab_manager and vocab_manager.is_oov(pred_word):
938
+ continue
939
+ if pred_score < 0.12:
940
+ continue
941
+ is_original_common = current_score > 0.001
942
+ if is_original_common:
943
+ if pred_score > current_score * 1000:
944
+ refined_words[i] = pred_word
945
+ break
946
+ else:
947
+ if pred_score > current_score * 50 and pred_score > 0.2:
948
+ refined_words[i] = pred_word
949
+ break
950
+ return ' '.join(refined_words)
951
+
952
+ def calculate_sentence_score(self, text: str) -> float:
953
+ words = text.split()
954
+ if not words:
955
+ return 0.0
956
+ total_score = 0.0
957
+ scored_words = 0
958
+ for i, word in enumerate(words):
959
+ score = self.score_with_mlm(text, i, word)
960
+ total_score += score
961
+ scored_words += 1
962
+ if scored_words == 0:
963
+ return 0.0
964
+ return total_score / scored_words
965
+
966
+
967
+ # ═══════════════════════════════════════════════════════════════════════════════
968
+ # MAIN SPELL CHECKER CLASS
969
+ # ═══════════════════════════════════════════════════════════════════════════════
970
+
971
+ class ArabicSpellChecker:
972
+ """Main Arabic Spell Checker class"""
973
+
974
+ def __init__(self, model, tokenizer, device, use_contextual: bool = True):
975
+ self.model = model
976
+ self.tokenizer = tokenizer
977
+ self.device = device
978
+
979
+ self.postprocessor = AraSpellPostProcessor()
980
+ self.classifier = ErrorClassifier()
981
+ self.rules = RulesBasedCorrector()
982
+ self.validator = OutputValidator()
983
+ self.vocab_manager = VocabularyManager(tokenizer)
984
+ self.edit_corrector = EditDistanceCorrector(tokenizer)
985
+ self.split_merge = SplitMergeSpecialist(self.vocab_manager)
986
+ self.word_aligner = WordAligner(self.vocab_manager)
987
+
988
+ self.use_contextual = use_contextual
989
+ if use_contextual:
990
+ try:
991
+ self.contextual = ContextualCorrector()
992
+ logger.info("Contextual correction enabled")
993
+ except Exception as e:
994
+ logger.warning(f"Contextual correction disabled: {e}")
995
+ self.contextual = None
996
+ self.use_contextual = False
997
+ else:
998
+ self.contextual = None
999
+
1000
+ def _fix_repeated_end_chars(self, text: str) -> str:
1001
+ text = re.sub(r'([ا-ي])\1+\b', r'\1', text)
1002
+ return text
1003
+
1004
+ def _fix_merged_with_errors(self, text: str) -> str:
1005
+ text = re.sub(r'ال([ا-ي])\1+([ا-ي]{2,})', r'ال\2', text)
1006
+ text = re.sub(r'\b([ا-ي]{3,})([ا-ي])\2+\b', r'\1\2', text)
1007
+ return text
1008
+
1009
+ def _split_merged_words_linguistic(self, text: str) -> str:
1010
+ text = re.sub(
1011
+ r'\b(في|من|إلى|الى|حتى|منذ|خلال|بعد|قبل)(ال)?([ا-ي]{3,})',
1012
+ r'\1 \2\3', text
1013
+ )
1014
+ text = re.sub(r'\b(كل)([ا-ي]{3,})', r'\1 \2', text)
1015
+ text = re.sub(r'([ا-ي]{3,})(ال)([ا-ي]{3,})', r'\1 \2\3', text)
1016
+ text = re.sub(r'\b([بلك])(ال)?([ا-ي]{3,})', r'\1 \2\3', text)
1017
+ text = re.sub(r'([ا-ي]{4,})(عليكم|عليك|عليه|عليها)', r'\1 \2', text)
1018
+ text = re.sub(r'([ا-ي]{3,})(على|عن)([ا-ي]{3,})', r'\1 \2 \3', text)
1019
+ return text
1020
+
1021
+ def _split_long_words_heuristic(self, text: str, max_length: int = 15) -> str:
1022
+ words = text.split()
1023
+ result = []
1024
+ for word in words:
1025
+ if len(word) <= max_length:
1026
+ result.append(word)
1027
+ continue
1028
+ if 'ال' in word[2:]:
1029
+ parts = word.split('ال', 1)
1030
+ if len(parts[0]) >= 2 and len(parts[1]) >= 3:
1031
+ result.extend([parts[0], 'ال' + parts[1]])
1032
+ continue
1033
+ if len(word) >= 8:
1034
+ split_found = False
1035
+ for split_pos in [2, 3]:
1036
+ prefix = word[:split_pos]
1037
+ suffix = word[split_pos:]
1038
+ if prefix in ['في', 'من', 'على', 'عن', 'مع', 'كل', 'ب', 'ل', 'ك']:
1039
+ result.extend([prefix, suffix])
1040
+ split_found = True
1041
+ break
1042
+ if not split_found:
1043
+ result.append(word)
1044
+ else:
1045
+ result.append(word)
1046
+ return ' '.join(result)
1047
+
1048
+ def _normalize_tanween_patterns(self, text: str) -> str:
1049
+ text = re.sub(r'([ا-ي]{2,})أ\b', r'\1اً', text)
1050
+ text = re.sub(r'\s+أ\s+', ' ', text)
1051
+ text = re.sub(r'\b([بلك])\s+([ا-ي])', r'\1\2', text)
1052
+ return text
1053
+
1054
+ def preprocess(self, text: str) -> str:
1055
+ """Preprocessing pipeline"""
1056
+ text = self.postprocessor.remove_harakat(text)
1057
+ text = self.postprocessor.remove_tatweel(text)
1058
+ text = self.postprocessor.normalize_special_chars(text)
1059
+ text = self._fix_repeated_end_chars(text)
1060
+ text = self._fix_merged_with_errors(text)
1061
+ text = self._split_merged_words_linguistic(text)
1062
+ text = self._split_long_words_heuristic(text)
1063
+ text = self._normalize_tanween_patterns(text)
1064
+ text = self.postprocessor.merge_separated_al(text)
1065
+ text = self.postprocessor.unified_collapse_repeated(text)
1066
+ text = self.rules.fix_char_substitution(text)
1067
+ text = self.rules.fix_char_repetition(text)
1068
+ text = self.postprocessor.normalize_spaces(text)
1069
+ return text
1070
+
1071
+ def postprocess(self, text: str, original: str = "") -> str:
1072
+ """Postprocessing pipeline"""
1073
+ return self.postprocessor.full_postprocess(text, original, vocab_manager=self.vocab_manager)
1074
+
1075
+ def model_inference(self, text: str, num_return_sequences: int = 5) -> List[str]:
1076
+ """Run seq2seq model inference and return top candidates."""
1077
+ inputs = self.tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=128)
1078
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
1079
+ with torch.no_grad():
1080
+ outputs = self.model.generate(
1081
+ **inputs,
1082
+ num_beams=5,
1083
+ num_return_sequences=num_return_sequences,
1084
+ early_stopping=True,
1085
+ return_dict_in_generate=True,
1086
+ output_scores=True
1087
+ )
1088
+ candidates = self.tokenizer.batch_decode(outputs.sequences, skip_special_tokens=True)
1089
+ self._last_beam_scores = {}
1090
+ if hasattr(outputs, 'sequences_scores') and outputs.sequences_scores is not None:
1091
+ scores = outputs.sequences_scores.tolist()
1092
+ for cand, score in zip(candidates, scores):
1093
+ self._last_beam_scores[cand] = score
1094
+ return candidates
1095
+
1096
+ def correct(self, text: str) -> str:
1097
+ """
1098
+ Main correction pipeline (RERANKING APPROACH)
1099
+
1100
+ Steps:
1101
+ 1. Preprocess
1102
+ 2. Generate Candidates (Model Beams + Baseline)
1103
+ 3. Rerank Candidates (Validator + Fluency)
1104
+ 4. Select Best
1105
+ 5. Postprocess
1106
+ """
1107
+ if not text or not text.strip():
1108
+ return text
1109
+
1110
+ original = text
1111
+
1112
+ # 1. Preprocess
1113
+ preprocessed_text = self.preprocess(text)
1114
+
1115
+ # 2. Classify error type
1116
+ error_type = self.classifier.classify(preprocessed_text)
1117
+
1118
+ # 3. Generate Candidates
1119
+ candidates = []
1120
+ candidates.append(preprocessed_text)
1121
+
1122
+ rules_candidate = self.rules.advanced_heuristic_repair(text)
1123
+ candidates.append(rules_candidate)
1124
+
1125
+ edit_candidate = self.edit_corrector.generate_candidate(text)
1126
+ if edit_candidate != text and edit_candidate != rules_candidate:
1127
+ candidates.append(edit_candidate)
1128
+
1129
+ raw_model_output = None
1130
+ try:
1131
+ model_candidates = self.model_inference(preprocessed_text, num_return_sequences=5)
1132
+ raw_model_output = model_candidates[0] if model_candidates else None
1133
+ candidates.extend(model_candidates)
1134
+
1135
+ if model_candidates:
1136
+ hybrid_candidate = self.word_aligner.align_words(preprocessed_text, model_candidates[0])
1137
+ if hybrid_candidate not in candidates:
1138
+ candidates.append(hybrid_candidate)
1139
+ for beam in model_candidates[1:3]:
1140
+ hybrid_beam = self.word_aligner.align_words(preprocessed_text, beam)
1141
+ if hybrid_beam not in candidates:
1142
+ candidates.append(hybrid_beam)
1143
+
1144
+ if model_candidates and len(model_candidates) >= 3:
1145
+ try:
1146
+ beam_word_lists = [c.split() for c in model_candidates]
1147
+ max_words = max(len(wl) for wl in beam_word_lists)
1148
+ voted_words = []
1149
+ for pos in range(max_words):
1150
+ words_at_pos = []
1151
+ for wl in beam_word_lists:
1152
+ if pos < len(wl):
1153
+ words_at_pos.append(wl[pos])
1154
+ if words_at_pos:
1155
+ most_common = Counter(words_at_pos).most_common(1)[0][0]
1156
+ voted_words.append(most_common)
1157
+ voted_candidate = ' '.join(voted_words)
1158
+ if voted_candidate not in candidates:
1159
+ candidates.append(voted_candidate)
1160
+ except Exception:
1161
+ pass
1162
+ except Exception as e:
1163
+ logger.warning(f"Model inference failed: {e}")
1164
+
1165
+ # Remove duplicates
1166
+ unique_candidates = []
1167
+ seen = set()
1168
+ for c in candidates:
1169
+ if c not in seen:
1170
+ unique_candidates.append(c)
1171
+ seen.add(c)
1172
+ candidates = unique_candidates
1173
+
1174
+ # 4. Rerank Candidates
1175
+ best_candidate = preprocessed_text
1176
+ best_score = -1.0
1177
+ candidate_scores = []
1178
+
1179
+ for cand in candidates:
1180
+ is_valid, reason = self.validator.validate(original, cand, error_type.value)
1181
+ if len(cand) < len(original) * 0.5:
1182
+ is_valid = False
1183
+ reason = "too_short"
1184
+
1185
+ input_oov_count = self.vocab_manager.count_oov_words(original)
1186
+ cand_oov_count = self.vocab_manager.count_oov_words(cand)
1187
+ vocab_boost = 1.0
1188
+
1189
+ if input_oov_count > 0 and cand_oov_count < input_oov_count:
1190
+ oov_reduction = input_oov_count - cand_oov_count
1191
+ vocab_boost = 1.0 + (oov_reduction * 0.3)
1192
+ if cand_oov_count == 0 and self.vocab_manager.all_words_iv(cand):
1193
+ if not is_valid and reason not in ["empty_output"]:
1194
+ is_valid = True
1195
+ reason = "vocab_aware_accept"
1196
+ elif cand_oov_count > input_oov_count:
1197
+ vocab_boost = 0.5
1198
+ elif input_oov_count == 0 and cand_oov_count == 0:
1199
+ vocab_boost = 1.0
1200
+
1201
+ validity_factor = 1.0 if is_valid else 0.001
1202
+
1203
+ fluency_score = 0.0
1204
+ if self.use_contextual and self.contextual:
1205
+ try:
1206
+ fluency_score = self.contextual.calculate_sentence_score(cand)
1207
+ except Exception as e:
1208
+ logger.warning(f"Scoring failed: {e}")
1209
+ fluency_score = 0.5
1210
+ else:
1211
+ fluency_score = 1.0
1212
+
1213
+ dist = VocabularyManager.damerau_levenshtein_distance(preprocessed_text, cand)
1214
+ max_len = max(len(preprocessed_text), len(cand), 1)
1215
+ similarity = 1.0 - (dist / max_len)
1216
+ if cand == preprocessed_text:
1217
+ similarity = 1.0
1218
+
1219
+ keyboard_bonus = 1.0
1220
+ input_words = preprocessed_text.split()
1221
+ cand_words = cand.split()
1222
+ if len(input_words) == len(cand_words):
1223
+ for iw, cw in zip(input_words, cand_words):
1224
+ if iw != cw and len(iw) == len(cw):
1225
+ for ic, cc in zip(iw, cw):
1226
+ if ic != cc and RulesBasedCorrector.is_keyboard_neighbor(ic, cc):
1227
+ keyboard_bonus *= 1.05
1228
+
1229
+ if fluency_score > 0.85 and cand_oov_count == 0:
1230
+ if not is_valid and reason in ["too_short", "low_character_similarity", "word_count_mismatch"]:
1231
+ if len(cand) >= len(original) * 0.4:
1232
+ is_valid = True
1233
+ reason = "high_confidence_override"
1234
+ vocab_boost *= 1.2
1235
+ validity_factor = 1.0
1236
+
1237
+ fluency_exp = 0.3
1238
+ similarity_exp = 3.0
1239
+ beam_boost = 1.0
1240
+ if raw_model_output and cand == raw_model_output:
1241
+ beam_boost = 1.15
1242
+
1243
+ final_score = (fluency_score ** fluency_exp) * (similarity ** similarity_exp) * validity_factor * vocab_boost * keyboard_bonus * beam_boost
1244
+
1245
+ candidate_scores.append({
1246
+ 'text': cand, 'is_valid': is_valid, 'reason': reason,
1247
+ 'fluency': fluency_score, 'similarity': similarity,
1248
+ 'vocab_boost': vocab_boost, 'input_oov': input_oov_count,
1249
+ 'cand_oov': cand_oov_count, 'final_score': final_score
1250
+ })
1251
+
1252
+ if final_score > best_score:
1253
+ best_score = final_score
1254
+ best_candidate = cand
1255
+
1256
+ # Output Quality Scoring
1257
+ if best_candidate != preprocessed_text:
1258
+ preprocessed_score = 0.0
1259
+ for cs in candidate_scores:
1260
+ if cs['text'] == preprocessed_text:
1261
+ preprocessed_score = cs['final_score']
1262
+ break
1263
+ if preprocessed_score > 0 and best_score < preprocessed_score * 1.05:
1264
+ best_oov = self.vocab_manager.count_oov_words(best_candidate)
1265
+ prep_oov = self.vocab_manager.count_oov_words(preprocessed_text)
1266
+ if best_oov > prep_oov:
1267
+ best_candidate = preprocessed_text
1268
+ best_score = preprocessed_score
1269
+
1270
+ # Contextual Validation Layer
1271
+ if best_candidate != preprocessed_text and self.use_contextual and self.contextual:
1272
+ try:
1273
+ input_fluency = self.contextual.calculate_sentence_score(preprocessed_text)
1274
+ best_fluency = 0.0
1275
+ for cs in candidate_scores:
1276
+ if cs['text'] == best_candidate:
1277
+ best_fluency = cs['fluency']
1278
+ break
1279
+ if input_fluency > 0 and best_fluency > 0:
1280
+ if input_fluency > best_fluency * 1.5:
1281
+ input_oov = self.vocab_manager.count_oov_words(preprocessed_text)
1282
+ best_oov = self.vocab_manager.count_oov_words(best_candidate)
1283
+ if input_oov <= best_oov:
1284
+ best_candidate = preprocessed_text
1285
+ except Exception:
1286
+ pass
1287
+
1288
+ # 5. Postprocess Winner
1289
+ result = self.postprocess(best_candidate, original)
1290
+
1291
+ # IV-Safe Postprocessing Check
1292
+ if result != best_candidate:
1293
+ result_words = result.split()
1294
+ best_words = best_candidate.split()
1295
+ if len(result_words) == len(best_words):
1296
+ fixed_words = []
1297
+ for idx_fw, (rw, bw) in enumerate(zip(result_words, best_words)):
1298
+ if rw != bw:
1299
+ bw_iv = self.vocab_manager.is_iv(bw)
1300
+ rw_iv = self.vocab_manager.is_iv(rw)
1301
+ if bw_iv and not rw_iv:
1302
+ fixed_words.append(bw)
1303
+ else:
1304
+ fixed_words.append(rw)
1305
+ else:
1306
+ fixed_words.append(rw)
1307
+ result = ' '.join(fixed_words)
1308
+
1309
+ # 6. Contextual fine-tuning
1310
+ if self.use_contextual and self.contextual:
1311
+ if len(result) > 3:
1312
+ result = self.contextual.refine_sentence_with_mask(
1313
+ result, vocab_manager=self.vocab_manager,
1314
+ raw_model_output=raw_model_output
1315
+ )
1316
+
1317
+ # 7. Safe Split/Merge Post-processing
1318
+ result = self.split_merge.merge_fragments(result)
1319
+
1320
+ # 8. Output Stability Test
1321
+ if result != preprocessed_text and raw_model_output:
1322
+ try:
1323
+ re_preprocessed = self.preprocess(result)
1324
+ stability_dist = VocabularyManager.damerau_levenshtein_distance(result, re_preprocessed)
1325
+ result_len = max(len(result), 1)
1326
+ if stability_dist > 0:
1327
+ stability_ratio = stability_dist / result_len
1328
+ if stability_ratio > 0.15:
1329
+ raw_re = self.preprocess(raw_model_output)
1330
+ raw_stability = VocabularyManager.damerau_levenshtein_distance(
1331
+ raw_model_output, raw_re
1332
+ ) / max(len(raw_model_output), 1)
1333
+ if raw_stability < stability_ratio:
1334
+ raw_oov = self.vocab_manager.count_oov_words(raw_model_output)
1335
+ our_oov = self.vocab_manager.count_oov_words(result)
1336
+ if raw_oov <= our_oov:
1337
+ result = raw_model_output
1338
+ except Exception:
1339
+ pass
1340
+
1341
+ # 9. Bidirectional Word-Level Validation
1342
+ if raw_model_output and result != raw_model_output:
1343
+ result_words = result.split()
1344
+ raw_words = raw_model_output.split()
1345
+ if len(result_words) == len(raw_words):
1346
+ corrected_words = []
1347
+ changed = False
1348
+ for rw, raw_w in zip(result_words, raw_words):
1349
+ if rw != raw_w:
1350
+ rw_iv = self.vocab_manager.is_iv(rw)
1351
+ raw_iv = self.vocab_manager.is_iv(raw_w)
1352
+ if not rw_iv and raw_iv:
1353
+ corrected_words.append(raw_w)
1354
+ changed = True
1355
+ elif rw_iv and raw_iv:
1356
+ input_words_list = preprocessed_text.split()
1357
+ idx = len(corrected_words)
1358
+ if idx < len(input_words_list):
1359
+ input_w = input_words_list[idx]
1360
+ rw_dist = Levenshtein.distance(input_w, rw)
1361
+ raw_dist = Levenshtein.distance(input_w, raw_w)
1362
+ if raw_dist < rw_dist:
1363
+ corrected_words.append(raw_w)
1364
+ changed = True
1365
+ else:
1366
+ corrected_words.append(rw)
1367
+ else:
1368
+ corrected_words.append(rw)
1369
+ else:
1370
+ corrected_words.append(rw)
1371
+ else:
1372
+ corrected_words.append(rw)
1373
+ if changed:
1374
+ new_result = ' '.join(corrected_words)
1375
+ new_oov = self.vocab_manager.count_oov_words(new_result)
1376
+ old_oov = self.vocab_manager.count_oov_words(result)
1377
+ if new_oov <= old_oov:
1378
+ result = new_result
1379
+
1380
+ # 10. SAFETY NET
1381
+ if raw_model_output and raw_model_output != result:
1382
+ raw_oov = self.vocab_manager.count_oov_words(raw_model_output)
1383
+ our_oov = self.vocab_manager.count_oov_words(result)
1384
+ if raw_oov == 0 and our_oov > 0:
1385
+ is_valid, reason = self.validator.validate(original, raw_model_output, "mixed")
1386
+ if is_valid or reason == "space_leniency_accept":
1387
+ result = raw_model_output
1388
+ elif raw_oov == 0 and our_oov == 0:
1389
+ raw_dist = VocabularyManager.damerau_levenshtein_distance(original, raw_model_output)
1390
+ our_dist = VocabularyManager.damerau_levenshtein_distance(original, result)
1391
+ result_vs_raw_dist = VocabularyManager.damerau_levenshtein_distance(result, raw_model_output)
1392
+ if raw_dist < our_dist and result_vs_raw_dist <= 3:
1393
+ raw_valid, _ = self.validator.validate(original, raw_model_output, "mixed")
1394
+ if raw_valid:
1395
+ result = raw_model_output
1396
+ elif raw_oov == 0:
1397
+ raw_wc = len(raw_model_output.split())
1398
+ our_wc = len(result.split())
1399
+ if raw_wc != our_wc:
1400
+ raw_dist = VocabularyManager.damerau_levenshtein_distance(original, raw_model_output)
1401
+ our_dist = VocabularyManager.damerau_levenshtein_distance(original, result)
1402
+ if raw_dist < our_dist:
1403
+ raw_valid, _ = self.validator.validate(original, raw_model_output, "mixed")
1404
+ if raw_valid:
1405
+ result = raw_model_output
1406
+
1407
+ return result
src/nlp/spelling/araspell_service.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AraSpell Service — Lazy-loaded Arabic spelling correction.
3
+
4
+ Model is loaded on first request and kept in memory.
5
+ Pre-downloaded during Docker build; loaded from HF cache at runtime (no network needed).
6
+ """
7
+
8
+ import os
9
+ import logging
10
+ import time
11
+ import torch
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # ── Lazy-loaded singletons ──
16
+ _spell_checker = None
17
+ _load_error = None
18
+
19
+ # Model identifiers
20
+ MODEL_REPO = 'bayan10/AraSpell-Model'
21
+ MODEL_FILENAME = 'last_model.pt'
22
+ TOKENIZER_NAME = 'aubmindlab/bert-base-arabertv02'
23
+
24
+
25
+ def get_spelling_model():
26
+ """
27
+ Lazy-load the spelling model on first call.
28
+ Returns the ArabicSpellChecker instance, or raises RuntimeError if loading fails.
29
+ """
30
+ global _spell_checker, _load_error
31
+
32
+ if _spell_checker is not None:
33
+ return _spell_checker
34
+
35
+ if _load_error is not None:
36
+ raise RuntimeError(f"Spelling model previously failed to load: {_load_error}")
37
+
38
+ try:
39
+ t0 = time.time()
40
+ logger.info("Loading AraSpell spelling model (lazy init)...")
41
+
42
+ from huggingface_hub import hf_hub_download
43
+ from transformers import AutoTokenizer, EncoderDecoderModel
44
+
45
+ # 1. Download checkpoint (from HF cache — pre-downloaded in Docker build)
46
+ logger.info(f"Resolving checkpoint: {MODEL_REPO}/{MODEL_FILENAME}")
47
+ model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILENAME)
48
+ logger.info(f"Checkpoint path: {model_path}")
49
+
50
+ # 2. Load tokenizer
51
+ logger.info(f"Loading tokenizer: {TOKENIZER_NAME}")
52
+ tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_NAME)
53
+
54
+ # 3. Build encoder-decoder model from AraBERT
55
+ logger.info("Building EncoderDecoderModel from AraBERT...")
56
+ model = EncoderDecoderModel.from_encoder_decoder_pretrained(
57
+ TOKENIZER_NAME, TOKENIZER_NAME
58
+ )
59
+
60
+ # 4. Configure generation
61
+ model.config.decoder_start_token_id = tokenizer.cls_token_id
62
+ model.config.pad_token_id = tokenizer.pad_token_id
63
+ model.config.eos_token_id = tokenizer.sep_token_id
64
+ model.generation_config.max_length = 128
65
+ model.generation_config.decoder_start_token_id = tokenizer.cls_token_id
66
+ model.generation_config.pad_token_id = tokenizer.pad_token_id
67
+ model.generation_config.eos_token_id = tokenizer.sep_token_id
68
+
69
+ # 5. Load trained weights
70
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
71
+ logger.info(f"Loading checkpoint weights on {device}...")
72
+ checkpoint = torch.load(model_path, map_location=device, weights_only=False)
73
+ model.load_state_dict(checkpoint['model_state_dict'], strict=False)
74
+ model = model.to(device)
75
+ model.eval()
76
+
77
+ epoch = checkpoint.get('epoch', 'N/A')
78
+ logger.info(f"Spelling model loaded on {device}, epoch: {epoch}")
79
+
80
+ # 6. Initialize the spell checker pipeline (contextual=False to save RAM)
81
+ from nlp.spelling.araspell_rules import ArabicSpellChecker
82
+ _spell_checker = ArabicSpellChecker(
83
+ model, tokenizer, device, use_contextual=False
84
+ )
85
+
86
+ elapsed = time.time() - t0
87
+ logger.info(f"AraSpell ready in {elapsed:.1f}s")
88
+ return _spell_checker
89
+
90
+ except Exception as e:
91
+ import traceback
92
+ _load_error = str(e)
93
+ logger.error(f"Failed to load spelling model: {e}")
94
+ logger.error(traceback.format_exc())
95
+ raise RuntimeError(f"Spelling model load failed: {e}")
96
+
97
+
98
+ def is_loaded() -> bool:
99
+ """Check if the spelling model is loaded."""
100
+ return _spell_checker is not None
101
+
102
+
103
+ def get_load_error() -> str:
104
+ """Return the last load error, or empty string."""
105
+ return _load_error or ""