Commit ·
41ea30b
1
Parent(s): e7c1175
Fix: 10 critical NLP logic bugs in grammar, spelling, and punctuation to prevent false positives
Browse files
src/nlp/grammar/grammar_rules.py
CHANGED
|
@@ -159,7 +159,7 @@ class ArabicGrammarGuard:
|
|
| 159 |
disambig_tokens = self.mle.disambiguate(tokens)
|
| 160 |
|
| 161 |
nasb_particles = ['أن', 'ان', 'لن', 'كي', 'لكي', 'حتى', 'حتي', 'إذن', 'اذا']
|
| 162 |
-
jazm_particles = ['لم', 'لما'
|
| 163 |
|
| 164 |
corrected_tokens = []
|
| 165 |
|
|
@@ -250,31 +250,36 @@ class ArabicGrammarGuard:
|
|
| 250 |
stem = m.group(2)
|
| 251 |
suffix = m.group(3)
|
| 252 |
full_word = stem + suffix
|
| 253 |
-
|
| 254 |
-
if
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
if
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
# (وبالمبرمجون) -> (وبالمبرمجين)
|
| 264 |
# FIX-33b: Same blocklist protection as first regex
|
| 265 |
def _attached_prep_replace(m):
|
| 266 |
-
prefix = m.group(1)
|
| 267 |
stem = m.group(2)
|
| 268 |
suffix = m.group(3)
|
| 269 |
-
full_word = 'ال' + stem + suffix
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
if
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
| 276 |
|
| 277 |
-
text = re.sub(r'\b([وف]?[بلكف])ال([أ-ي]{
|
| 278 |
|
| 279 |
# (ولمهندسون) -> (ولمهندسين)
|
| 280 |
# FIX-33b: Same protection — reconstruct full word for blocklist
|
|
@@ -310,8 +315,8 @@ class ArabicGrammarGuard:
|
|
| 310 |
if word == 'ذو': return 'ذا'
|
| 311 |
if word == 'فو': return 'فا'
|
| 312 |
if word == 'حمو': return 'حما'
|
| 313 |
-
if word.endswith('ون') and word not in ('قانون', 'فرعون', 'كانون', 'معجون', 'طاعون', 'مجنون'): return word[:-2] + 'ين'
|
| 314 |
-
if word.endswith('ان') and word not in ('امتحان', 'إنسان', 'ميدان', 'سلطان', 'شيطان'): return word[:-2] + 'ين'
|
| 315 |
elif target_case == 'n': # Marfoo'
|
| 316 |
if word in ('أبا', 'أبي'): return 'أبو'
|
| 317 |
if word in ('أخا', 'أخي'): return 'أخو'
|
|
@@ -574,13 +579,13 @@ class ArabicGrammarGuard:
|
|
| 574 |
|
| 575 |
# FIX-PC010: Add targeted safe regex for Nasb/Jazm particles + verb
|
| 576 |
# Only match clear present tense verbs starting with ي/ت/ن/أ and ending in ون
|
| 577 |
-
text = re.sub(r'\b(أن|ان|لن|كي|حتى|لم|لما)\s+([يتا][\u0600-\u06FF]{2,})ون\b',
|
| 578 |
r'\1 \2وا', text)
|
| 579 |
|
| 580 |
return text
|
| 581 |
|
| 582 |
def fix_conditional_sentences(self, text):
|
| 583 |
-
conditional_particles = {'إن', 'ان', 'م
|
| 584 |
tokens = simple_word_tokenize(text)
|
| 585 |
disambig_tokens = self.mle.disambiguate(tokens)
|
| 586 |
corrected_tokens = list(tokens)
|
|
@@ -700,9 +705,11 @@ class ArabicGrammarGuard:
|
|
| 700 |
if base_adj:
|
| 701 |
if w1.endswith('ون') or w1.endswith('ين') or w1_gen == 'm':
|
| 702 |
is_nom = w1.endswith('ون')
|
| 703 |
-
|
|
|
|
| 704 |
elif w1.endswith('ات') or w1_gen == 'f':
|
| 705 |
-
|
|
|
|
| 706 |
|
| 707 |
return " ".join(corrected_tokens)
|
| 708 |
|
|
|
|
| 159 |
disambig_tokens = self.mle.disambiguate(tokens)
|
| 160 |
|
| 161 |
nasb_particles = ['أن', 'ان', 'لن', 'كي', 'لكي', 'حتى', 'حتي', 'إذن', 'اذا']
|
| 162 |
+
jazm_particles = ['لم', 'لما']
|
| 163 |
|
| 164 |
corrected_tokens = []
|
| 165 |
|
|
|
|
| 250 |
stem = m.group(2)
|
| 251 |
suffix = m.group(3)
|
| 252 |
full_word = stem + suffix
|
| 253 |
+
|
| 254 |
+
# Use camel-tools disambiguation to determine if it's really a dual/plural
|
| 255 |
+
tokens = simple_word_tokenize(full_word)
|
| 256 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 257 |
+
if disambig_tokens and disambig_tokens[0].analyses:
|
| 258 |
+
num = disambig_tokens[0].analyses[0].analysis.get('num', 's')
|
| 259 |
+
# Only apply ين suffix if the word is actually Dual or Plural
|
| 260 |
+
if num in ['d', 'p']:
|
| 261 |
+
return f'{prep} {stem}ين'
|
| 262 |
+
return m.group(0)
|
| 263 |
+
|
| 264 |
+
text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن|حتى))\s+([أ-ي]{3,})(ون|ان)\b', _prep_replace, text)
|
| 265 |
|
| 266 |
# (وبالمبرمجون) -> (وبالمبرمجين)
|
| 267 |
# FIX-33b: Same blocklist protection as first regex
|
| 268 |
def _attached_prep_replace(m):
|
| 269 |
+
prefix = m.group(1)
|
| 270 |
stem = m.group(2)
|
| 271 |
suffix = m.group(3)
|
| 272 |
+
full_word = 'ال' + stem + suffix
|
| 273 |
+
|
| 274 |
+
tokens = simple_word_tokenize(full_word)
|
| 275 |
+
disambig_tokens = self.mle.disambiguate(tokens)
|
| 276 |
+
if disambig_tokens and disambig_tokens[0].analyses:
|
| 277 |
+
num = disambig_tokens[0].analyses[0].analysis.get('num', 's')
|
| 278 |
+
if num in ['d', 'p']:
|
| 279 |
+
return f'{prefix}ال{stem}ين'
|
| 280 |
+
return m.group(0)
|
| 281 |
|
| 282 |
+
text = re.sub(r'\b([وف]?[بلكف])ال([أ-ي]{3,})(ون|ان)\b', _attached_prep_replace, text)
|
| 283 |
|
| 284 |
# (ولمهندسون) -> (ولمهندسين)
|
| 285 |
# FIX-33b: Same protection — reconstruct full word for blocklist
|
|
|
|
| 315 |
if word == 'ذو': return 'ذا'
|
| 316 |
if word == 'فو': return 'فا'
|
| 317 |
if word == 'حمو': return 'حما'
|
| 318 |
+
if word.endswith('ون') and num == 'p' and word not in ('قانون', 'فرعون', 'كانون', 'معجون', 'طاعون', 'مجنون'): return word[:-2] + 'ين'
|
| 319 |
+
if word.endswith('ان') and num == 'd' and word not in ('امتحان', 'إنسان', 'ميدان', 'سلطان', 'شيطان'): return word[:-2] + 'ين'
|
| 320 |
elif target_case == 'n': # Marfoo'
|
| 321 |
if word in ('أبا', 'أبي'): return 'أبو'
|
| 322 |
if word in ('أخا', 'أخي'): return 'أخو'
|
|
|
|
| 579 |
|
| 580 |
# FIX-PC010: Add targeted safe regex for Nasb/Jazm particles + verb
|
| 581 |
# Only match clear present tense verbs starting with ي/ت/ن/أ and ending in ون
|
| 582 |
+
text = re.sub(r'\b(أن|ان|لن|كي|حتى|لم|لما)\s+([يتاأن][\u0600-\u06FF]{2,})ون\b',
|
| 583 |
r'\1 \2وا', text)
|
| 584 |
|
| 585 |
return text
|
| 586 |
|
| 587 |
def fix_conditional_sentences(self, text):
|
| 588 |
+
conditional_particles = {'إن', 'ان', 'متى', 'متي', 'مهما', 'أينما', 'حيثما', 'أيان', 'ايان', 'كيفما', 'أنى', 'اني'}
|
| 589 |
tokens = simple_word_tokenize(text)
|
| 590 |
disambig_tokens = self.mle.disambiguate(tokens)
|
| 591 |
corrected_tokens = list(tokens)
|
|
|
|
| 705 |
if base_adj:
|
| 706 |
if w1.endswith('ون') or w1.endswith('ين') or w1_gen == 'm':
|
| 707 |
is_nom = w1.endswith('ون')
|
| 708 |
+
if not w2.endswith('ان') and not w2.endswith('ين') and not w2.endswith('ات'):
|
| 709 |
+
corrected_tokens[i+1] = base_adj + ('ون' if is_nom else 'ين')
|
| 710 |
elif w1.endswith('ات') or w1_gen == 'f':
|
| 711 |
+
if not w2.endswith('ان') and not w2.endswith('ين') and not w2.endswith('ات'):
|
| 712 |
+
corrected_tokens[i+1] = base_adj + 'ات'
|
| 713 |
|
| 714 |
return " ".join(corrected_tokens)
|
| 715 |
|
src/nlp/punctuation/punctuation_rules.py
CHANGED
|
@@ -91,7 +91,7 @@ def arabic_postprocessing(text: str) -> str:
|
|
| 91 |
if prev_word.startswith(('ال', 'لل', 'بال', 'فال', 'وال', 'كال')):
|
| 92 |
return match.group(0) # Preserve the colon! Do not delete it.
|
| 93 |
|
| 94 |
-
return
|
| 95 |
|
| 96 |
text = re.sub(r'([^:]+)(:)', _colon_guard, text)
|
| 97 |
|
|
|
|
| 91 |
if prev_word.startswith(('ال', 'لل', 'بال', 'فال', 'وال', 'كال')):
|
| 92 |
return match.group(0) # Preserve the colon! Do not delete it.
|
| 93 |
|
| 94 |
+
return context + ' '
|
| 95 |
|
| 96 |
text = re.sub(r'([^:]+)(:)', _colon_guard, text)
|
| 97 |
|
src/nlp/spelling/araspell_rules.py
CHANGED
|
@@ -129,14 +129,9 @@ class AraSpellPostProcessor:
|
|
| 129 |
@staticmethod
|
| 130 |
def remove_duplicate_words(text: str) -> str:
|
| 131 |
"""Remove consecutive duplicate words. e.g. كتاب كتاب → كتاب"""
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
result = [words[0]]
|
| 136 |
-
for i in range(1, len(words)):
|
| 137 |
-
if words[i] != words[i-1]:
|
| 138 |
-
result.append(words[i])
|
| 139 |
-
return ' '.join(result)
|
| 140 |
|
| 141 |
@staticmethod
|
| 142 |
def normalize_spaces(text: str) -> str:
|
|
@@ -337,11 +332,11 @@ class AraSpellPostProcessor:
|
|
| 337 |
if any(word.endswith(e) for e in PROTECTED_ENDINGS):
|
| 338 |
result.append(word)
|
| 339 |
continue
|
| 340 |
-
if word in PROTECTED_HA_WORDS:
|
| 341 |
result.append(word)
|
| 342 |
continue
|
| 343 |
if len(word) >= 3 and word.endswith('ه'):
|
| 344 |
-
if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS:
|
| 345 |
candidate_with_ta = word[:-1] + 'ة'
|
| 346 |
# Default: prefer ة (correct Arabic orthography for feminine nouns)
|
| 347 |
if vocab_manager:
|
|
@@ -389,11 +384,8 @@ class AraSpellPostProcessor:
|
|
| 389 |
if i + 1 < len(words):
|
| 390 |
next_word = words[i + 1]
|
| 391 |
# Bug 2.11: Destroys Badal structures (الأستاذ أستاذ -> الأستاذ)
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
result.append(keep)
|
| 395 |
-
i += 2
|
| 396 |
-
continue
|
| 397 |
result.append(word)
|
| 398 |
i += 1
|
| 399 |
return ' '.join(result)
|
|
@@ -1177,7 +1169,15 @@ class ArabicSpellChecker:
|
|
| 1177 |
logger.info("[MLM/CONTEXTUAL] Disabled by configuration (use_contextual=False)")
|
| 1178 |
|
| 1179 |
def _fix_repeated_end_chars(self, text: str) -> str:
|
| 1180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1181 |
return text
|
| 1182 |
|
| 1183 |
def _fix_merged_with_errors(self, text: str) -> str:
|
|
|
|
| 129 |
@staticmethod
|
| 130 |
def remove_duplicate_words(text: str) -> str:
|
| 131 |
"""Remove consecutive duplicate words. e.g. كتاب كتاب → كتاب"""
|
| 132 |
+
# Bug 2.11: Destroys rhetorical repetition (التوكيد اللفظي) like "صفا صفا".
|
| 133 |
+
# Disabled as it destroys valid Arabic phrases.
|
| 134 |
+
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
@staticmethod
|
| 137 |
def normalize_spaces(text: str) -> str:
|
|
|
|
| 332 |
if any(word.endswith(e) for e in PROTECTED_ENDINGS):
|
| 333 |
result.append(word)
|
| 334 |
continue
|
| 335 |
+
if word in PROTECTED_HA_WORDS or word in ['هذه', 'هاته']:
|
| 336 |
result.append(word)
|
| 337 |
continue
|
| 338 |
if len(word) >= 3 and word.endswith('ه'):
|
| 339 |
+
if word[-2] in AraSpellPostProcessor.ARABIC_CONSONANTS or word[-2] in 'اويءؤئ':
|
| 340 |
candidate_with_ta = word[:-1] + 'ة'
|
| 341 |
# Default: prefer ة (correct Arabic orthography for feminine nouns)
|
| 342 |
if vocab_manager:
|
|
|
|
| 384 |
if i + 1 < len(words):
|
| 385 |
next_word = words[i + 1]
|
| 386 |
# Bug 2.11: Destroys Badal structures (الأستاذ أستاذ -> الأستاذ)
|
| 387 |
+
# and Rhetorical Repetition (التوكيد اللفظي)
|
| 388 |
+
# Removed the aggressive duplicate word deletion.
|
|
|
|
|
|
|
|
|
|
| 389 |
result.append(word)
|
| 390 |
i += 1
|
| 391 |
return ' '.join(result)
|
|
|
|
| 1169 |
logger.info("[MLM/CONTEXTUAL] Disabled by configuration (use_contextual=False)")
|
| 1170 |
|
| 1171 |
def _fix_repeated_end_chars(self, text: str) -> str:
|
| 1172 |
+
# Exclude 'ي' if it is preceded by a Kasra or another Yaa (e.g., يحيي)
|
| 1173 |
+
def _replace_repeated(m):
|
| 1174 |
+
w = m.group(0)
|
| 1175 |
+
char = m.group(2)
|
| 1176 |
+
if w.endswith('يي'):
|
| 1177 |
+
if self.vocab_manager and self.vocab_manager.is_iv(w):
|
| 1178 |
+
return w
|
| 1179 |
+
return m.group(1) + char
|
| 1180 |
+
text = re.sub(r'\b([^\s]+?)([\u0621-\u064A])\2+\b', _replace_repeated, text)
|
| 1181 |
return text
|
| 1182 |
|
| 1183 |
def _fix_merged_with_errors(self, text: str) -> str:
|