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 +600 -502
- Dockerfile +20 -5
- gradio Spelling.py +210 -0
- src/app.py +69 -1
- src/nlp/__init__.py +1 -0
- src/nlp/spelling/__init__.py +1 -0
- src/nlp/spelling/araspell_rules.py +1407 -0
- src/nlp/spelling/araspell_service.py +105 -0
src/ara_spell.py → AraSpell.py
RENAMED
|
@@ -1,35 +1,54 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
|
| 4 |
import re
|
|
|
|
|
|
|
| 5 |
import torch
|
| 6 |
import os
|
|
|
|
| 7 |
from transformers import AutoTokenizer, EncoderDecoderModel
|
| 8 |
import Levenshtein
|
| 9 |
-
import jellyfish
|
| 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 |
-
#
|
| 20 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 21 |
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 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
|
| 51 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 52 |
|
| 53 |
class AraSpellPostProcessor:
|
| 54 |
-
"""
|
| 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 |
-
|
| 92 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 386 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 387 |
|
| 388 |
class ErrorClassifier:
|
|
@@ -427,18 +444,12 @@ class ErrorClassifier:
|
|
| 427 |
else:
|
| 428 |
return ErrorType.CLEAN
|
| 429 |
|
| 430 |
-
|
| 431 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 432 |
-
#
|
| 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
|
| 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
|
| 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 |
-
|
| 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
|
| 769 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 770 |
|
| 771 |
class WordAligner:
|
|
@@ -848,12 +899,26 @@ class WordAligner:
|
|
| 848 |
return input_word
|
| 849 |
|
| 850 |
# Case 4: Both OOV
|
| 851 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
return output_word
|
| 853 |
|
| 854 |
-
|
| 855 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 856 |
-
# SPLIT/MERGE SPECIALIST
|
| 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 |
-
|
| 901 |
-
|
|
|
|
|
|
|
| 902 |
"""
|
| 903 |
-
# Short words: don't split
|
| 904 |
-
if len(word) <
|
| 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) +
|
| 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(
|
| 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: السوري ة → السورية
|
| 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
|
| 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 |
-
|
| 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.
|
| 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.
|
| 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 |
-
|
| 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 |
-
|
| 1564 |
-
|
| 1565 |
-
# 1. Length Check
|
| 1566 |
-
if abs(len(pred_word) - len(word)) > 2:
|
| 1567 |
continue
|
| 1568 |
|
| 1569 |
-
#
|
| 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 |
-
#
|
| 1580 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1590 |
is_original_common = current_score > 0.001
|
| 1591 |
|
| 1592 |
if is_original_common:
|
| 1593 |
-
|
| 1594 |
-
if pred_score > current_score * 500:
|
| 1595 |
refined_words[i] = pred_word
|
| 1596 |
break
|
| 1597 |
else:
|
| 1598 |
-
|
| 1599 |
-
if pred_score > current_score * 10 or pred_score > 0.1:
|
| 1600 |
refined_words[i] = pred_word
|
| 1601 |
-
break
|
| 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)
|
| 1679 |
self.edit_corrector = EditDistanceCorrector(tokenizer) # Edit Distance candidates
|
| 1680 |
-
self.split_merge = SplitMergeSpecialist(self.vocab_manager)
|
| 1681 |
|
| 1682 |
-
#
|
| 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 |
-
|
| 1692 |
except Exception as e:
|
| 1693 |
-
|
| 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 |
-
#
|
| 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
|
| 1877 |
-
|
| 1878 |
-
|
| 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 |
-
|
| 1889 |
-
|
| 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
|
| 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
|
| 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 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 2033 |
fluency_score = 0.5 # Default fallback
|
| 2034 |
else:
|
| 2035 |
fluency_score = 1.0
|
| 2036 |
|
| 2037 |
-
# C. Similarity Score (Damerau-Levenshtein Distance
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2065 |
-
|
| 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 |
-
#
|
| 2074 |
-
|
| 2075 |
-
|
| 2076 |
-
|
| 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 **
|
| 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,
|
| 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 |
-
#
|
|
|
|
| 2103 |
if self.use_contextual and self.contextual:
|
| 2104 |
if len(result) > 3:
|
| 2105 |
-
result = self.contextual.refine_sentence_with_mask(
|
|
|
|
|
|
|
|
|
|
| 2106 |
|
| 2107 |
-
# 7.
|
| 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("
|
| 2116 |
-
print("
|
| 2117 |
-
print("
|
| 2118 |
-
print(" - ErrorClassifier")
|
| 2119 |
-
print(" - RulesBasedCorrector")
|
| 2120 |
-
print(" - OutputValidator")
|
| 2121 |
-
print(" - ContextualCorrector")
|
| 2122 |
-
print(" - ArabicSpellChecker")
|
| 2123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2124 |
|
| 2125 |
-
print("
|
|
|
|
|
|
|
| 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
|
| 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('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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':
|
| 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 ""
|