FIX-33/34/35: Fix infinite loop — grammar corruption + spelling conflict
Browse filesFIX-33: Grammar rules fix_prepositions_advanced was corrupting
الامتحان→الامتحين. The regex blindly replaced ان→ين after prepositions,
but الامتحان is a root noun not a dual. Added blocklist of ~60 common
root-form nouns and skip all ال-prefixed words ending in ان.
FIX-34: Remove auto-re-analysis from Apply All in editor.js.
When spelling and grammar disagree (spelling strips ذهبن→ذهب,
grammar re-adds ذهبوا→ذهبن), the auto-re-analysis creates an
infinite loop of contradictory suggestions.
FIX-35: Spelling no longer strips valid conjugation suffixes.
The insertion fix was accepting ذهبن→ذهب (removing final ن) as
'extra char removed'. But ن is a feminine plural verb suffix.
Now blocks removal of suffix chars (ن,ت,ا,ي,ة,و,ه) from word
endings, preventing spelling from undoing grammar corrections.
- src/app.py +20 -0
- src/js/editor.js +4 -4
- src/nlp/grammar/grammar_rules.py +32 -2
|
@@ -1062,6 +1062,26 @@ def _is_small_spelling_change(orig_word, corr_word, vocab_manager=None):
|
|
| 1062 |
_orig_oov = not vocab_manager.is_iv(orig_word)
|
| 1063 |
_corr_iv = vocab_manager.is_iv(corr_word)
|
| 1064 |
if _orig_oov and _corr_iv:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1065 |
logger.info(
|
| 1066 |
f"[SPELLING] Insertion fix accepted (OOV→IV): "
|
| 1067 |
f"'{orig_word}'→'{corr_word}' (extra char removed)"
|
|
|
|
| 1062 |
_orig_oov = not vocab_manager.is_iv(orig_word)
|
| 1063 |
_corr_iv = vocab_manager.is_iv(corr_word)
|
| 1064 |
if _orig_oov and _corr_iv:
|
| 1065 |
+
# FIX-35: Don't strip valid conjugation suffixes.
|
| 1066 |
+
# If the "extra char" is at the END and is a common
|
| 1067 |
+
# Arabic verb/noun suffix, this is likely a grammar
|
| 1068 |
+
# conjugation, not a typo. Skip it.
|
| 1069 |
+
_CONJUGATION_SUFFIXES = {'ن', 'ت', 'ا', 'ي', 'ة', 'و', 'ه'}
|
| 1070 |
+
_removed_char = None
|
| 1071 |
+
for _di2 in range(len(orig_word)):
|
| 1072 |
+
if orig_word[:_di2] + orig_word[_di2 + 1:] == corr_word:
|
| 1073 |
+
_removed_char = orig_word[_di2]
|
| 1074 |
+
_removed_pos = _di2
|
| 1075 |
+
break
|
| 1076 |
+
if (_removed_char in _CONJUGATION_SUFFIXES
|
| 1077 |
+
and _removed_pos == len(orig_word) - 1
|
| 1078 |
+
and len(corr_word) >= 3):
|
| 1079 |
+
logger.info(
|
| 1080 |
+
f"[SPELLING] Rejected suffix strip: "
|
| 1081 |
+
f"'{orig_word}'→'{corr_word}' "
|
| 1082 |
+
f"(removing suffix '{_removed_char}' likely strips conjugation)"
|
| 1083 |
+
)
|
| 1084 |
+
return 0.0
|
| 1085 |
logger.info(
|
| 1086 |
f"[SPELLING] Insertion fix accepted (OOV→IV): "
|
| 1087 |
f"'{orig_word}'→'{corr_word}' (extra char removed)"
|
|
@@ -719,12 +719,12 @@ function applyAllSuggestions() {
|
|
| 719 |
updateSuggestionsList([]);
|
| 720 |
if (typeof showToast === 'function') showToast('✓ تم تطبيق ' + suggestions.length + ' تصحيح');
|
| 721 |
} finally {
|
| 722 |
-
// FIX-32: Delay guard reset
|
| 723 |
-
// preventing input events from triggering double analysis.
|
| 724 |
setTimeout(() => { _isApplyingSuggestion = false; }, 400);
|
| 725 |
}
|
| 726 |
-
//
|
| 727 |
-
|
|
|
|
| 728 |
}
|
| 729 |
|
| 730 |
function clearEditor() {
|
|
|
|
| 719 |
updateSuggestionsList([]);
|
| 720 |
if (typeof showToast === 'function') showToast('✓ تم تطبيق ' + suggestions.length + ' تصحيح');
|
| 721 |
} finally {
|
| 722 |
+
// FIX-32: Delay guard reset
|
|
|
|
| 723 |
setTimeout(() => { _isApplyingSuggestion = false; }, 400);
|
| 724 |
}
|
| 725 |
+
// FIX-34: Do NOT auto-re-analyze after Apply All.
|
| 726 |
+
// This caused an infinite loop when spelling/grammar disagree.
|
| 727 |
+
// User can click "Analyze" manually if they want to re-check.
|
| 728 |
}
|
| 729 |
|
| 730 |
function clearEditor() {
|
|
@@ -201,11 +201,41 @@ class ArabicGrammarGuard:
|
|
| 201 |
|
| 202 |
return text
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
def fix_prepositions_advanced(self, text):
|
| 205 |
# Allow conjunctions (و، ف) before prepositions
|
| 206 |
# (في المهندسون) -> (في المهندسين)
|
| 207 |
-
#
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
# (وبالمبرمجون) -> (وبالمبرمجين)
|
| 211 |
text = re.sub(r'\b([وف]?[بلكف])ال([أ-ي]{4,})(ون|ان)\b', r'\1ال\2ين', text)
|
|
|
|
| 201 |
|
| 202 |
return text
|
| 203 |
|
| 204 |
+
# FIX-33: Words ending in ان that are root-form nouns, NOT duals/plurals.
|
| 205 |
+
# These must never have ان→ين replacement.
|
| 206 |
+
_PREP_BLOCKLIST = {
|
| 207 |
+
'الامتحان', 'امتحان', 'الإنسان', 'إنسان', 'انسان', 'الانسان',
|
| 208 |
+
'الميدان', 'ميدان', 'البرلمان', 'برلمان', 'السلطان', 'سلطان',
|
| 209 |
+
'العنوان', 'عنوان', 'الديوان', 'ديوان', 'البستان', 'بستان',
|
| 210 |
+
'البنيان', 'بنيان', 'الإيمان', 'إيمان', 'ايمان', 'الايمان',
|
| 211 |
+
'الأمان', 'أمان', 'امان', 'الامان', 'العدوان', 'عدوان',
|
| 212 |
+
'البيان', 'بيان', 'البرهان', 'برهان', 'الشيطان', 'شيطان',
|
| 213 |
+
'الأذان', 'أذان', 'السودان', 'لبنان', 'عمان', 'الأردن',
|
| 214 |
+
'الحيوان', 'حيوان', 'القرآن', 'قرآن', 'الدخان', 'دخان',
|
| 215 |
+
'المكان', 'مكان', 'الزمان', 'زمان', 'الجدران', 'جدران',
|
| 216 |
+
'النيران', 'نيران', 'الألوان', 'ألوان', 'البلدان', 'بلدان',
|
| 217 |
+
'الأوطان', 'أوطان', 'الأبدان', 'أبدان', 'الأركان', 'أركان',
|
| 218 |
+
'الفرسان', 'فرسان', 'الغزلان', 'غزلان', 'القضبان', 'قضبان',
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
def fix_prepositions_advanced(self, text):
|
| 222 |
# Allow conjunctions (و، ف) before prepositions
|
| 223 |
# (في المهندسون) -> (في المهندسين)
|
| 224 |
+
# FIX-33: Use callback to skip root-form nouns ending in ان
|
| 225 |
+
def _prep_replace(m):
|
| 226 |
+
prep = m.group(1)
|
| 227 |
+
stem = m.group(2)
|
| 228 |
+
suffix = m.group(3)
|
| 229 |
+
full_word = stem + suffix
|
| 230 |
+
# Skip words in blocklist (root nouns, not duals)
|
| 231 |
+
if full_word in self._PREP_BLOCKLIST:
|
| 232 |
+
return m.group(0) # return unchanged
|
| 233 |
+
# Skip ال-prefixed words ending in ان — almost always root nouns
|
| 234 |
+
if stem.startswith('ال') and suffix == 'ان':
|
| 235 |
+
return m.group(0) # return unchanged
|
| 236 |
+
return f'{prep} {stem}ين'
|
| 237 |
+
|
| 238 |
+
text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن|حتى))\s+([أ-ي]{4,})(ون|ان)\b', _prep_replace, text)
|
| 239 |
|
| 240 |
# (وبالمبرمجون) -> (وبالمبرمجين)
|
| 241 |
text = re.sub(r'\b([وف]?[بلكف])ال([أ-ي]{4,})(ون|ان)\b', r'\1ال\2ين', text)
|