youssefreda9 commited on
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
- # Skip words in blocklist (root nouns, not duals)
254
- if full_word in self._PREP_BLOCKLIST:
255
- return m.group(0) # return unchanged
256
- # Skip ال-prefixed words ending in ان — almost always root nouns
257
- if stem.startswith('ال') and suffix == 'ان':
258
- return m.group(0) # return unchanged
259
- return f'{prep} {stem}ين'
260
-
261
- text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن|حتى))\s+([أ-ي]{4,})(ون|ان)\b', _prep_replace, text)
 
 
 
262
 
263
  # (وبالمبرمجون) -> (وبالمبرمجين)
264
  # FIX-33b: Same blocklist protection as first regex
265
  def _attached_prep_replace(m):
266
- prefix = m.group(1) # وب، ب، فب، ول، ل، etc.
267
  stem = m.group(2)
268
  suffix = m.group(3)
269
- full_word = 'ال' + stem + suffix # reconstruct with ال for blocklist check
270
- if full_word in self._PREP_BLOCKLIST:
271
- return m.group(0)
272
- # Words ending in ان with 4+ char stems are almost always root nouns
273
- if suffix == 'ان':
274
- return m.group(0)
275
- return f'{prefix}ال{stem}ين'
 
 
276
 
277
- text = re.sub(r'\b([وف]?[بلكف])ال([أ-ي]{4,})(ون|ان)\b', _attached_prep_replace, text)
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
- corrected_tokens[i+1] = base_adj + ('ون' if is_nom else 'ين')
 
704
  elif w1.endswith('ات') or w1_gen == 'f':
705
- corrected_tokens[i+1] = base_adj + 'ات'
 
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 match.group(0)
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
- words = text.split()
133
- if len(words) < 2:
134
- return text
135
- result = [words[0]]
136
- for i in range(1, len(words)):
137
- if words[i] != words[i-1]:
138
- result.append(words[i])
139
- return ' '.join(result)
140
 
141
  @staticmethod
142
  def normalize_spaces(text: str) -> str:
@@ -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
- if word == next_word: # Only remove exact duplicates, not normalized duplicates
393
- keep = next_word if next_word.startswith('ال') and not word.startswith('ال') else word
394
- result.append(keep)
395
- i += 2
396
- continue
397
  result.append(word)
398
  i += 1
399
  return ' '.join(result)
@@ -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
- text = re.sub(r'([ا-ي])\1+\b', r'\1', text)
 
 
 
 
 
 
 
 
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: