| |
| |
| |
|
|
| import re |
| import logging |
| from camel_tools.tokenizers.word import simple_word_tokenize |
| from camel_tools.disambig.mle import MLEDisambiguator |
|
|
| logger = logging.getLogger(__name__) |
|
|
| KNOWN_FEMININE_NOUNS = { |
| 'السيارة', 'سيارة', 'المدرسة', 'مدرسة', 'المدينة', 'مدينة', |
| 'البنت', 'الشمس', 'الأرض', 'الطالبة', 'طالبة', |
| 'الجامعة', 'جامعة', 'الشركة', 'شركة', 'الحكومة', 'حكومة', |
| 'الغرفة', 'غرفة', 'الحديقة', 'حديقة', 'المكتبة', 'مكتبة', |
| 'الدولة', 'دولة', 'الرحلة', 'رحلة', 'اللغة', 'لغة', |
| 'القصة', 'قصة', 'الفكرة', 'فكرة', 'النتيجة', 'نتيجة', |
| } |
|
|
| |
| MASC_TO_FEM_ADJ = { |
| 'جميل': 'جميلة', 'كبير': 'كبيرة', 'صغير': 'صغيرة', |
| 'طويل': 'طويلة', 'قصير': 'قصيرة', 'جديد': 'جديدة', |
| 'قديم': 'قديمة', 'بعيد': 'بعيدة', 'قريب': 'قريبة', |
| 'سريع': 'سريعة', 'بطيء': 'بطيئة', 'واسع': 'واسعة', |
| 'ضيق': 'ضيقة', 'عميق': 'عميقة', 'خفيف': 'خفيفة', |
| 'ثقيل': 'ثقيلة', 'نظيف': 'نظيفة', 'مشرق': 'مشرقة', |
| 'ذكي': 'ذكية', 'غني': 'غنية', 'فقير': 'فقيرة', |
| 'متفوق': 'متفوقة', 'مجتهد': 'مجتهدة', 'ممتاز': 'ممتازة', |
| } |
|
|
|
|
| class ArabicGrammarGuard: |
| def __init__(self): |
| self.mle = MLEDisambiguator.pretrained() |
|
|
| self.number_words = ["واحد", "اثنان", "اثنين", "ثلاث", "أربع", "خمس", "ست", "سبع", "ثمان", "تسع", "عشر", |
| "عشرون", "عشرين", "ثلاثون", "ثلاثين", "أربعون", "أربعين", "خمسون", "خمسين", |
| "ستون", "ستين", "سبعون", "سبعين", "ثمانون", "ثمانين", "تسعون", "تسعين", "مائة", "ألف"] |
|
|
| self.asmaa_khamsa_roots = ['اب', 'اخ', 'حم', 'فو', 'ذو'] |
|
|
| def preserve_numbers(self, original_text, generated_text): |
| orig_digits = re.findall(r'\d+', original_text) |
| gen_digits = re.findall(r'\d+', generated_text) |
| if orig_digits and gen_digits and orig_digits != gen_digits: |
| return original_text |
|
|
| orig_words = [w for w in original_text.split() if any(num in w for num in self.number_words)] |
| gen_words = [w for w in generated_text.split() if any(num in w for num in self.number_words)] |
| if len(orig_words) > 0 and len(gen_words) > 0: |
| if not any(orig[:3] in gen for orig in orig_words for gen in gen_words): |
| return original_text |
| return generated_text |
|
|
| def fix_number_and_gender_agreement(self, text): |
| tokens = simple_word_tokenize(text) |
| disambig_tokens = self.mle.disambiguate(tokens) |
| corrected_tokens = list(tokens) |
|
|
| for i in range(len(disambig_tokens) - 1): |
| w1_info = disambig_tokens[i].analyses[0] if disambig_tokens[i].analyses else None |
| w2_info = disambig_tokens[i+1].analyses[0] if disambig_tokens[i+1].analyses else None |
| if not w1_info or not w2_info: continue |
|
|
| w1_pos = w1_info.analysis.get('pos', 'unknown') |
| w2_pos = w2_info.analysis.get('pos', 'unknown') |
| w1_word = corrected_tokens[i] |
| w2_word = corrected_tokens[i+1] |
|
|
| if w1_pos == 'verb' and w2_pos == 'noun': |
| if (w1_word.endswith('ون') or w1_word.endswith('وا')) and (w2_word.endswith('ون') or w2_word.endswith('ين')): |
| if w1_word.endswith('ون'): corrected_tokens[i] = w1_word[:-2] |
| elif w1_word.endswith('وا'): corrected_tokens[i] = w1_word[:-2] |
|
|
| return " ".join(corrected_tokens) |
|
|
| def smart_asmaa_khamsa_fix(self, text): |
| |
| return text |
|
|
| def _apply_jazm_to_verb(self, word, token_info): |
| |
| if token_info and token_info.analyses: |
| analysis = token_info.analyses[0].analysis |
| num = analysis.get('num', 's') |
| per = analysis.get('per', '3') |
| gen = analysis.get('gen', 'm') |
| |
| if num == 'p' and gen == 'm': |
| if word.endswith('ون'): |
| return word[:-2] + 'وا' |
| elif num == 'd': |
| if word.endswith('ان'): |
| return word[:-2] + 'ا' |
| elif num == 's' and per == '2' and gen == 'f': |
| if word.endswith('ين'): |
| return word[:-2] + 'ي' |
|
|
| |
| match = re.search(r'^(.*?)([يوىاَُِْ]?)$', word) |
| if match: |
| stem = match.group(1) |
| ending = match.group(2) |
|
|
| fatha_bases = ['سع', 'خش', 'رض', 'نس', 'بق', 'ر', 'نه', 'حظ', 'رع', 'أب', 'تمن', 'لق', 'هو', 'سل'] |
| damma_bases = ['دع', 'رج', 'شك', 'نم', 'غز', 'عف', 'سم', 'دن', 'بد', 'خل', 'عل'] |
| kasra_bases = ['مش', 'جر', 'قض', 'بك', 'هد', 'رم', 'أت', 'بن', 'ق', 'وف', 'شف', 'غن', 'عط', 'تق', 'شتر', 'عتن', 'ستدع', 'نته', 'رو'] |
|
|
| fatha_stems = {p + b for p in ['ي', 'ت', 'أ', 'ن'] for b in fatha_bases} |
| damma_stems = {p + b for p in ['ي', 'ت', 'أ', 'ن'] for b in damma_bases} |
| kasra_stems = {p + b for p in ['ي', 'ت', 'أ', 'ن'] for b in kasra_bases} |
|
|
| if stem in fatha_stems: |
| return stem + 'َ' |
| elif stem in damma_stems: |
| return stem + 'ُ' |
| elif stem in kasra_stems: |
| return stem + 'ِ' |
| elif ending == 'و' and len(stem) >= 2: |
| return stem + 'ُ' |
| elif ending == 'ي' and len(stem) >= 2: |
| return stem + 'ِ' |
| elif (ending == 'ى' or ending == 'ا') and len(stem) >= 2: |
| if not word.endswith('وا'): |
| return stem + 'َ' |
|
|
| return word |
|
|
| def _apply_nasb_to_verb(self, word, token_info): |
| |
| if token_info and token_info.analyses: |
| analysis = token_info.analyses[0].analysis |
| num = analysis.get('num', 's') |
| per = analysis.get('per', '3') |
| gen = analysis.get('gen', 'm') |
| |
| if num == 'p' and gen == 'm': |
| if word.endswith('ون'): |
| return word[:-2] + 'وا' |
| elif num == 'd': |
| if word.endswith('ان'): |
| return word[:-2] + 'ا' |
| elif num == 's' and per == '2' and gen == 'f': |
| if word.endswith('ين'): |
| return word[:-2] + 'ي' |
|
|
| |
| if word.endswith('و') and len(word) > 3: |
| return word + 'َ' |
| elif word.endswith('ي') and len(word) > 3: |
| return word + 'َ' |
| |
| return word |
|
|
| def fix_verbs_nasb_and_jazm(self, text): |
| tokens = simple_word_tokenize(text) |
| disambig_tokens = self.mle.disambiguate(tokens) |
|
|
| nasb_particles = ['أن', 'ان', 'لن', 'كي', 'لكي', 'حتى', 'حتي', 'إذن', 'اذا'] |
| jazm_particles = ['لم', 'لما'] |
|
|
| corrected_tokens = [] |
|
|
| for i, token_info in enumerate(disambig_tokens): |
| word = tokens[i] |
|
|
| pos_tag = token_info.analyses[0].analysis.get('pos', 'unknown') if token_info.analyses else 'unknown' |
|
|
| is_nasb_context = False |
| is_jazm_context = False |
|
|
| if i > 0: |
| prev_word = tokens[i-1] |
| if prev_word in nasb_particles: |
| is_nasb_context = True |
| if prev_word in jazm_particles: |
| is_jazm_context = True |
| |
| |
| if (word.startswith('ل') or word.startswith('ول') or word.startswith('فل')) and len(word) >= 4 and word[1] in ['ي', 'ت', 'ن', 'أ']: |
| is_nasb_context = True |
|
|
| is_present_tense = word.startswith('ي') or word.startswith('ت') or word.startswith('ن') or word.startswith('أ') or word.startswith('لي') or word.startswith('لت') or word.startswith('ولي') or word.startswith('فلي') |
| if (pos_tag == 'verb' or is_present_tense) and (is_nasb_context or is_jazm_context): |
| if is_jazm_context: |
| word = self._apply_jazm_to_verb(word, token_info) |
| elif is_nasb_context: |
| word = self._apply_nasb_to_verb(word, token_info) |
|
|
| corrected_tokens.append(word) |
| return " ".join(corrected_tokens) |
|
|
| def fix_gender_agreement(self, text): |
|
|
| text = re.sub(r'\bأحد عشر\s+([أ-ي]+ة)\b', r'إحدى عشرة \1', text) |
| text = re.sub(r'\bأحد عشرة\s+([أ-ي]+ة)\b', r'إحدى عشرة \1', text) |
|
|
| text = re.sub(r'\bإحدى عشرة\s+([أ-ي]+ا|رجل[اأ]|طالب[اأ]|مهندس[اأ])\b', r'أحد عشر \1', text) |
| text = re.sub(r'\bإحدى عشر\s+([أ-ي]+ا|رجل[اأ]|طالب[اأ]|مهندس[اأ])\b', r'أحد عشر \1', text) |
|
|
| |
| |
| |
| words = text.split() |
| KNOWN_MASC_TA_MARBUTA = {'خليفة', 'أسامة', 'حمزة', 'طلحة', 'معاوية', 'عبيدة', 'قضاة', 'دعاة', 'رماة', 'حماة'} |
| for i in range(len(words) - 1): |
| noun = words[i] |
| adj = words[i + 1] |
| |
| clean_noun = noun[2:] if noun.startswith('ال') else noun |
| if clean_noun in KNOWN_MASC_TA_MARBUTA: |
| continue |
| is_fem_noun = (noun in KNOWN_FEMININE_NOUNS or |
| (noun.endswith('ة') and len(noun) >= 3) or |
| (noun.startswith('ال') and noun.endswith('ة'))) |
| if is_fem_noun and adj in MASC_TO_FEM_ADJ: |
| words[i + 1] = MASC_TO_FEM_ADJ[adj] |
| text = ' '.join(words) |
|
|
| return text |
|
|
| |
| |
| _PREP_BLOCKLIST = { |
| 'الامتحان', 'امتحان', 'الإنسان', 'إنسان', 'انسان', 'الانسان', |
| 'الميدان', 'ميدان', 'البرلمان', 'برلمان', 'السلطان', 'سلطان', |
| 'العنوان', 'عنوان', 'الديوان', 'ديوان', 'البستان', 'بستان', |
| 'البنيان', 'بنيان', 'الإيمان', 'إيمان', 'ايمان', 'الايمان', |
| 'الأمان', 'أمان', 'امان', 'الامان', 'العدوان', 'عدوان', |
| 'البيان', 'بيان', 'البرهان', 'برهان', 'الشيطان', 'شيطان', |
| 'الأذان', 'أذان', 'السودان', 'لبنان', 'عمان', 'الأردن', |
| 'الحيوان', 'حيوان', 'القرآن', 'قرآن', 'الدخان', 'دخان', |
| 'المكان', 'مكان', 'الزمان', 'زمان', 'الجدران', 'جدران', |
| 'النيران', 'نيران', 'الألوان', 'ألوان', 'البلدان', 'بلدان', |
| 'الأوطان', 'أوطان', 'الأبدان', 'أبدان', 'الأركان', 'أركان', |
| 'الفرسان', 'فرسان', 'الغزلان', 'غزلان', 'القضبان', 'قضبان', |
| 'فرعون', 'قانون', 'القانون', 'كانون', 'قارون', 'طاعون', |
| 'عربون', 'هارون', 'زيدون', 'معجون', 'مجنون', 'زيتون', 'صابون', |
| 'الفرعون', 'قوانين' |
| } |
|
|
| def fix_prepositions_advanced(self, text): |
| |
| |
| |
| def _prep_replace(m): |
| prep = m.group(1) |
| stem = m.group(2) |
| suffix = m.group(3) |
| full_word = stem + suffix |
| |
| |
| tokens = simple_word_tokenize(full_word) |
| disambig_tokens = self.mle.disambiguate(tokens) |
| if disambig_tokens and disambig_tokens[0].analyses: |
| num = disambig_tokens[0].analyses[0].analysis.get('num', 's') |
| |
| if num in ['d', 'p']: |
| return f'{prep} {stem}ين' |
| return m.group(0) |
|
|
| text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن|حتى))\s+([أ-ي]{3,})(ون|ان)\b', _prep_replace, text) |
|
|
| |
| |
| def _attached_prep_replace(m): |
| prefix = m.group(1) |
| stem = m.group(2) |
| suffix = m.group(3) |
| full_word = 'ال' + stem + suffix |
| |
| tokens = simple_word_tokenize(full_word) |
| disambig_tokens = self.mle.disambiguate(tokens) |
| if disambig_tokens and disambig_tokens[0].analyses: |
| num = disambig_tokens[0].analyses[0].analysis.get('num', 's') |
| if num in ['d', 'p']: |
| return f'{prefix}ال{stem}ين' |
| return m.group(0) |
|
|
| text = re.sub(r'\b([وف]?[بلكف])ال([أ-ي]{3,})(ون|ان)\b', _attached_prep_replace, text) |
|
|
| |
| |
| def _lam_prep_replace(m): |
| prefix = m.group(1) |
| stem = m.group(2) |
| suffix = m.group(3) |
| |
| if (stem + suffix) in self._PREP_BLOCKLIST: |
| return m.group(0) |
| if suffix == 'ان': |
| return m.group(0) |
| return f'{prefix}{stem}ين' |
|
|
| text = re.sub(r'\b([وف]?ل)([أ-ي]{4,})(ون|ان)\b', _lam_prep_replace, text) |
| return text |
|
|
| def fix_kana_and_inna(self, text): |
| """ |
| Fix cases (Nominative/Accusative) of nouns after Inna and Kana sisters. |
| """ |
| tokens = simple_word_tokenize(text) |
| disambig_tokens = self.mle.disambiguate(tokens) |
| corrected_tokens = list(tokens) |
| |
| INNA_SISTERS = {'إن', 'أن', 'كأن', 'لكن', 'ليت', 'لعل', 'ان'} |
| KANA_SISTERS = {'كان', 'أصبح', 'اصبح', 'أضحى', 'اضحى', 'ظل', 'أمسى', 'امسى', 'بات', 'صار', 'ليس'} |
| |
| def get_corrected_case(word, target_case, num): |
| if target_case == 'a': |
| if word == 'أبو': return 'أبا' |
| if word == 'أخو': return 'أخا' |
| if word == 'ذو': return 'ذا' |
| if word == 'فو': return 'فا' |
| if word == 'حمو': return 'حما' |
| if word.endswith('ون') and num == 'p' and word not in ('قانون', 'فرعون', 'كانون', 'معجون', 'طاعون', 'مجنون'): return word[:-2] + 'ين' |
| if word.endswith('ان') and num == 'd' and word not in ('امتحان', 'إنسان', 'ميدان', 'سلطان', 'شيطان'): return word[:-2] + 'ين' |
| elif target_case == 'n': |
| if word in ('أبا', 'أبي'): return 'أبو' |
| if word in ('أخا', 'أخي'): return 'أخو' |
| if word in ('ذا', 'ذي'): return 'ذو' |
| if word in ('فا', 'في'): return 'فو' |
| if word in ('حما', 'حمي'): return 'حمو' |
| if word.endswith('ين'): |
| if num == 'p': return word[:-2] + 'ون' |
| elif num == 'd': return word[:-2] + 'ان' |
| return word |
|
|
| state = None |
| noun_count = 0 |
| subject_num = 's' |
| |
| for i, t in enumerate(disambig_tokens): |
| word = corrected_tokens[i] |
| |
| if word in INNA_SISTERS: |
| state = 'inna' |
| noun_count = 0 |
| continue |
| elif word in KANA_SISTERS: |
| state = 'kana' |
| noun_count = 0 |
| continue |
| |
| if state and t.analyses: |
| analysis = t.analyses[0].analysis |
| pos = analysis.get('pos') |
| |
| |
| if pos in ('noun', 'adj', 'noun_prop'): |
| num = analysis.get('num', 's') |
| noun_count += 1 |
| |
| new_word = word |
| if state == 'inna': |
| if noun_count == 1: |
| subject_num = num |
| new_word = get_corrected_case(word, 'a', num) |
| elif noun_count == 2: |
| new_word = get_corrected_case(word, 'n', subject_num) |
| state = None |
| elif state == 'kana': |
| if noun_count == 1: |
| subject_num = num |
| new_word = get_corrected_case(word, 'n', num) |
| elif noun_count == 2: |
| new_word = get_corrected_case(word, 'a', subject_num) |
| state = None |
| |
| if new_word != word: |
| pattern = r'(?<![أ-يa-zA-Z])' + re.escape(word) + r'(?![أ-يa-zA-Z])' |
| text = re.sub(pattern, new_word, text, count=1) |
| corrected_tokens[i] = new_word |
| |
| elif pos == 'verb': |
| |
| state = None |
| |
| |
| if word in {'.', '،', ':', '؟', '!', '؛'}: |
| state = None |
| |
| return text |
|
|
| def fix_subject_verb_agreement(self, text): |
| """ |
| Fix G1: When a CONFIRMED plural noun PRECEDES a singular verb (SVO order), |
| the verb must agree in number and gender. |
| |
| Arabic rule: In VSO order, verb can be singular even with plural subject. |
| But in SVO order, subject-verb agreement is required. |
| |
| EXCLUSIONS: |
| - Pronouns (أنا, أنت, هو, etc.) — these are NOT plural |
| - Proper nouns — don't modify verbs after names |
| - Words tagged as singular by the disambiguator |
| """ |
| tokens = simple_word_tokenize(text) |
| if len(tokens) < 2: |
| return text |
| disambig_tokens = self.mle.disambiguate(tokens) |
| corrected_tokens = list(tokens) |
|
|
| |
| EXCLUDED_WORDS = { |
| |
| 'أنا', 'انا', 'أنت', 'انت', 'أنتِ', 'هو', 'هي', |
| 'نحن', 'أنتما', 'هما', |
| |
| 'كان', 'وكان', 'كانت', 'وكانت', 'ليس', 'ليست', |
| 'هذا', 'هذه', 'ذلك', 'تلك', 'هناك', |
| } |
|
|
| for i in range(len(disambig_tokens) - 1): |
| noun_info = disambig_tokens[i].analyses[0] if disambig_tokens[i].analyses else None |
| verb_info = disambig_tokens[i+1].analyses[0] if disambig_tokens[i+1].analyses else None |
| if not noun_info or not verb_info: |
| continue |
|
|
| noun_pos = noun_info.analysis.get('pos', 'unknown') |
| verb_pos = verb_info.analysis.get('pos', 'unknown') |
| noun_word = corrected_tokens[i] |
| verb_word = corrected_tokens[i+1] |
|
|
| |
| if noun_word in EXCLUDED_WORDS: |
| continue |
|
|
| |
| KNOWN_VERBS = {'بنى', 'طبخ', 'صمم', 'لعب', 'كتب', 'شرح', 'حضر', 'تدرب', 'وافق', 'أصدر', 'اصدر', 'بني', 'عمل'} |
|
|
| |
| if noun_pos != 'noun' or (verb_pos != 'verb' and verb_word not in KNOWN_VERBS): |
| continue |
|
|
| noun_num = noun_info.analysis.get('num', 's') |
| noun_gen = noun_info.analysis.get('gen', 'm') |
| verb_num = verb_info.analysis.get('num', 's') |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| is_plural_masc = False |
| is_plural_fem = False |
|
|
| KNOWN_PLURALS_MASC = { |
| 'الطلاب', 'طلاب', 'الرجال', 'رجال', 'الأولاد', 'أولاد', |
| 'الأطباء', 'أطباء', 'الاطباء', 'اطباء', |
| 'العمال', 'عمال', 'الشباب', 'الأبناء', |
| 'المهندسون', 'المعلمون', 'المهندسين', 'المعلمين', |
| |
| 'اللاعبون', 'اللاعبين', 'لاعبون', 'لاعبين', |
| 'المسلمون', 'المسلمين', 'مسلمون', 'مسلمين', |
| 'العرب', 'الناس', 'الأطفال', 'أطفال', 'اطفال', |
| 'الأصدقاء', 'أصدقاء', 'اصدقاء', |
| 'العلماء', 'علماء', 'الأعداء', 'أعداء', |
| 'الوزراء', 'وزراء', 'الأمراء', 'أمراء', |
| 'الكتّاب', 'كتّاب', 'الأدباء', 'أدباء', |
| 'السكان', 'سكان', 'الجنود', 'جنود', |
| 'الأساتذة', 'أساتذة', 'التلاميذ', 'تلاميذ', |
| 'المواطنون', 'المواطنين', 'المسؤولون', 'المسؤولين', |
| 'الطلبة', 'طلبة', 'الأقارب', 'أقارب', |
| } |
| KNOWN_PLURALS_FEM = { |
| 'الطالبات', 'طالبات', 'النساء', 'نساء', 'البنات', 'بنات', |
| 'المعلمات', 'معلمات', 'الأمهات', 'أمهات', |
| |
| 'المهندسات', 'مهندسات', 'الطبيبات', 'طبيبات', |
| 'اللاعبات', 'لاعبات', 'الممثلات', 'ممثلات', |
| } |
|
|
| if noun_num == 'd': |
| pass |
| elif noun_word in KNOWN_PLURALS_MASC: |
| is_plural_masc = True |
| elif noun_word in KNOWN_PLURALS_FEM: |
| is_plural_fem = True |
| elif (noun_word.endswith('ون') or (noun_word.endswith('ين') and noun_num == 'p')) and len(noun_word) >= 5: |
| |
| is_plural_masc = True |
| |
| |
| is_singular_fem = False |
| if not is_plural_masc and not is_plural_fem: |
| if noun_gen == 'f' or noun_word.endswith('ة') or noun_word in KNOWN_FEMININE_NOUNS: |
| is_singular_fem = True |
| else: |
| continue |
|
|
| |
| |
| _is_present = (verb_word.startswith('ي') or verb_word.startswith('ت') |
| or verb_word.startswith('ن') or verb_word.startswith('أ')) |
|
|
| if _is_present: |
| |
| if is_plural_fem: |
| if verb_word.endswith('ون') or verb_word.endswith('ين'): |
| verb_word = verb_word[:-2] |
| if not verb_word.endswith('ن') and not verb_word.endswith('نَ'): |
| corrected_tokens[i+1] = verb_word + 'ن' |
| elif is_plural_masc: |
| if verb_word.endswith('ن') and not verb_word.endswith('ون') and not verb_word.endswith('ين'): |
| verb_word = verb_word[:-1] |
| if (not verb_word.endswith('ون') and not verb_word.endswith('وا') |
| and not verb_word.endswith('ين')): |
| if verb_word.endswith('وَ'): |
| verb_word = verb_word[:-1] |
| corrected_tokens[i+1] = verb_word + 'ون' |
| elif is_singular_fem: |
| if verb_word.startswith('ي'): |
| corrected_tokens[i+1] = 'ت' + verb_word[1:] |
| else: |
| |
| if is_plural_fem: |
| if verb_word.endswith('وا') or verb_word.endswith('ون'): |
| verb_word = verb_word[:-2] |
| elif verb_word.endswith('ت') or verb_word.endswith('تْ') or verb_word.endswith('تَ') or verb_word.endswith('و'): |
| verb_word = verb_word[:-1] |
| if not verb_word.endswith('ن') and not verb_word.endswith('نَ'): |
| if verb_word.endswith('ى') or verb_word.endswith('ا'): |
| verb_word = verb_word[:-1] |
| corrected_tokens[i+1] = verb_word + 'ن' |
| elif is_plural_masc: |
| if verb_word.endswith('ت') or verb_word.endswith('تْ') or verb_word.endswith('تَ'): |
| verb_word = verb_word[:-1] |
| if verb_word.endswith('ن') and not verb_word.endswith('ون') and not verb_word.endswith('ين'): |
| verb_word = verb_word[:-1] |
| if (not verb_word.endswith('وا') and not verb_word.endswith('ون') |
| and not verb_word.endswith('ين')): |
| if verb_word.endswith('وَ'): |
| verb_word = verb_word[:-1] |
| elif verb_word.endswith('ى') or verb_word.endswith('ا'): |
| verb_word = verb_word[:-1] |
| corrected_tokens[i+1] = verb_word + 'وا' |
| elif is_singular_fem: |
| if not verb_word.endswith('ت') and not verb_word.endswith('تْ') and not verb_word.endswith('تَ'): |
| if verb_word.endswith('ى'): |
| verb_word = verb_word[:-1] + 'ا' |
| corrected_tokens[i+1] = verb_word + 'ت' |
|
|
| return " ".join(corrected_tokens) |
|
|
| def regex_rules_fallback(self, text): |
| def _add_hamza(word): |
| if word.startswith('ا') and not word.startswith('ال'): |
| return 'أ' + word[1:] |
| return word |
|
|
| |
| |
| text = re.sub(r'\b(إن|أن|كأن|لكن|لعل|ليت|ان)\s+(أبوك|ابوك|أخوك|اخوك|ذو|فوك)\b', |
| lambda m: f"{m.group(1)} {_add_hamza(m.group(2)).replace('و', 'ا')}", text) |
|
|
| |
| text = re.sub(r'\b(رأيت|شاهدت|قابلت|زرت|سمعت|عرفت|وجدت|أحب|أكرمت|صادفت)\s+(أبوك|ابوك|أخوك|اخوك|ذو|فوك)\b', |
| lambda m: f"{m.group(1)} {_add_hamza(m.group(2)).replace('و', 'ا')}", text) |
|
|
| |
| text = re.sub(r'\b([وف]?(?:في|من|إلى|الي|على|علي|عن))\s+(أبوك|ابوك|أباك|اباك|أخوك|اخوك|أخاك|اخاك|ذو|ذا)\b', |
| lambda m: f"{m.group(1)} {_add_hamza(m.group(2)).replace('و', 'ي').replace('ا', 'ي')}", text) |
|
|
| |
| text = re.sub(r'\b([وف]?[بل])(أبوك|ابوك|أباك|اباك|أخوك|اخوك|أخاك|اخاك|ذو|ذا)\b', |
| lambda m: f"{m.group(1)}{m.group(2).replace('و', 'ي').replace('ا', 'ي')}", text) |
|
|
| |
| |
| |
| |
| |
| |
| |
| text = re.sub(r'\b(أن|ان|لن|كي|حتى|لم|لما)\s+([يتاأن][\u0600-\u06FF]{2,})ون\b', |
| r'\1 \2وا', text) |
|
|
| return text |
|
|
| def fix_conditional_sentences(self, text): |
| conditional_particles = {'إن', 'ان', 'متى', 'متي', 'مهما', 'أينما', 'حيثما', 'أيان', 'ايان', 'كيفما', 'أنى', 'اني'} |
| tokens = simple_word_tokenize(text) |
| disambig_tokens = self.mle.disambiguate(tokens) |
| corrected_tokens = list(tokens) |
| |
| |
| has_2nd_person_context = False |
| |
| in_cond = False |
| verbs_jazmed = 0 |
| |
| for i, token_info in enumerate(disambig_tokens): |
| word = corrected_tokens[i] |
| pos_tag = token_info.analyses[0].analysis.get('pos', 'unknown') if token_info.analyses else 'unknown' |
| |
| if word in conditional_particles: |
| |
| |
| next_pos = 'unknown' |
| if i + 1 < len(disambig_tokens): |
| if disambig_tokens[i+1].analyses: |
| next_pos = disambig_tokens[i+1].analyses[0].analysis.get('pos', 'unknown') |
| |
| if next_pos == 'verb': |
| in_cond = True |
| verbs_jazmed = 0 |
| continue |
| |
| if in_cond and pos_tag == 'verb': |
| |
| word = self._apply_jazm_to_verb(word, token_info) |
| |
| corrected_tokens[i] = word |
| |
| verbs_jazmed += 1 |
| if verbs_jazmed >= 2: |
| in_cond = False |
| |
| return " ".join(corrected_tokens) |
|
|
| def fix_demonstrative_agreement(self, text): |
| tokens = simple_word_tokenize(text) |
| disambig_tokens = self.mle.disambiguate(tokens) |
| corrected_tokens = list(tokens) |
| |
| for i in range(len(disambig_tokens) - 1): |
| w1 = corrected_tokens[i] |
| w2 = corrected_tokens[i+1] |
| |
| if w1 not in ['هذا', 'هذه', 'هذان', 'هاتان', 'هذين', 'هاتين', 'هؤلاء']: |
| continue |
| |
| w2_info = disambig_tokens[i+1].analyses[0].analysis if disambig_tokens[i+1].analyses else {} |
| w2_num = w2_info.get('num', 's') |
| w2_gen = w2_info.get('gen', 'm') |
| |
| |
| if w2.endswith('تان') or w2.endswith('تين'): |
| if w2_num == 'd': |
| w2_gen = 'f' |
| |
| if w2_num == 'd': |
| is_nom = w2.endswith('ان') or w2.endswith('تان') |
| if w2_gen == 'f': |
| corrected_tokens[i] = 'هاتان' if is_nom else 'هاتين' |
| elif w2_gen == 'm': |
| corrected_tokens[i] = 'هذان' if is_nom else 'هذين' |
| |
| return " ".join(corrected_tokens) |
|
|
| def fix_noun_adjective_agreement_advanced(self, text): |
| tokens = simple_word_tokenize(text) |
| disambig_tokens = self.mle.disambiguate(tokens) |
| corrected_tokens = list(tokens) |
| |
| for i in range(len(disambig_tokens) - 1): |
| w1 = corrected_tokens[i] |
| w2 = corrected_tokens[i+1] |
| |
| w1_info = disambig_tokens[i].analyses[0].analysis if disambig_tokens[i].analyses else {} |
| w1_pos = w1_info.get('pos', 'unknown') |
| w1_num = w1_info.get('num', 's') |
| w1_gen = w1_info.get('gen', 'm') |
| |
| |
| if w1.endswith('تان') or w1.endswith('تين'): |
| w1_gen = 'f' |
| w1_num = 'd' |
| elif w1.endswith('ان') or w1.endswith('ين'): |
| if len(w1) > 4: |
| w1_num = 'd' |
| |
| |
| if w1_num == 'd' and w1_pos in ['noun', 'unknown', 'noun_prop']: |
| base_adj = None |
| for suffix in ['ان', 'ين', 'تان', 'تين', 'ة', 'ون', 'ات', '']: |
| stem = w2[:-len(suffix)] if suffix else w2 |
| if stem in MASC_TO_FEM_ADJ: |
| base_adj = stem |
| break |
| |
| if base_adj: |
| is_nom = w1.endswith('ان') or w1.endswith('تان') |
| if w1_gen == 'f': |
| corrected_tokens[i+1] = base_adj + ('تان' if is_nom else 'تين') |
| else: |
| corrected_tokens[i+1] = base_adj + ('ان' if is_nom else 'ين') |
| |
| |
| elif w1_num == 'p' and w1_pos in ['noun', 'unknown'] and (w1.endswith('ون') or w1.endswith('ين') or w1.endswith('ات')): |
| base_adj = None |
| for suffix in ['ان', 'ين', 'تان', 'تين', 'ة', 'ون', 'ات', 'ين', '']: |
| stem = w2[:-len(suffix)] if suffix else w2 |
| if stem in MASC_TO_FEM_ADJ: |
| base_adj = stem |
| break |
| |
| if base_adj: |
| if w1.endswith('ون') or w1.endswith('ين') or w1_gen == 'm': |
| is_nom = w1.endswith('ون') |
| if not w2.endswith('ان') and not w2.endswith('ين') and not w2.endswith('ات'): |
| corrected_tokens[i+1] = base_adj + ('ون' if is_nom else 'ين') |
| elif w1.endswith('ات') or w1_gen == 'f': |
| if not w2.endswith('ان') and not w2.endswith('ين') and not w2.endswith('ات'): |
| corrected_tokens[i+1] = base_adj + 'ات' |
|
|
| return " ".join(corrected_tokens) |
|
|
|
|
| def process(self, original_text, generated_text): |
| """Apply all grammar rules to model output.""" |
| text = self.preserve_numbers(original_text, generated_text) |
| |
| |
| |
| for bracket in ['{', '}', '[', ']', '<', '>']: |
| if f' {bracket}' not in original_text: |
| text = text.replace(f' {bracket}', bracket) |
| if f'{bracket} ' not in original_text: |
| text = text.replace(f'{bracket} ', bracket) |
| |
| orig_words = original_text.split() |
| corr_words = text.split() |
| if len(orig_words) == len(corr_words): |
| for i in range(len(orig_words)): |
| o = orig_words[i] |
| c = corr_words[i] |
| o_clean = o.rstrip('.,،؛;:!؟?()[]{}«»"\'…') |
| c_clean = c.rstrip('.,،؛;:!؟?()[]{}«»"\'…') |
| |
| |
| if re.search(r'[a-zA-Z]|\{|\[|<|#|@|://', o): |
| corr_words[i] = o |
| |
| if i > 0: |
| prev_o = orig_words[i-1] |
| prev_c = corr_words[i-1] |
| clean_c = prev_c.rstrip('.,،؛;:!؟?()[]{}«»"\'…') |
| if len(clean_c) <= len(prev_o) + 2 and clean_c.startswith(prev_o): |
| corr_words[i-1] = prev_o + prev_c[len(clean_c):] |
| if i < len(orig_words) - 1: |
| next_o = orig_words[i+1] |
| next_c = corr_words[i+1] |
| clean_c = next_c.rstrip('.,،؛;:!؟?()[]{}«»"\'…') |
| if len(clean_c) <= len(next_o) + 2 and clean_c.startswith(next_o): |
| corr_words[i+1] = next_o + next_c[len(clean_c):] |
| |
| |
| elif o_clean.endswith('ة') and not c_clean.endswith('ة') and o_clean[:-1] == c_clean: |
| corr_words[i] = o |
| text = " ".join(corr_words) |
|
|
| |
| |
| for rule_name, rule_fn in [ |
| ('fix_demonstrative_agreement', self.fix_demonstrative_agreement), |
| ('fix_number_and_gender_agreement', self.fix_number_and_gender_agreement), |
| ('smart_asmaa_khamsa_fix', self.smart_asmaa_khamsa_fix), |
| ('fix_verbs_nasb_and_jazm', self.fix_verbs_nasb_and_jazm), |
| ('fix_gender_agreement', self.fix_gender_agreement), |
| ('fix_noun_adjective_agreement_advanced', self.fix_noun_adjective_agreement_advanced), |
| ('fix_prepositions_advanced', self.fix_prepositions_advanced), |
| ('fix_subject_verb_agreement', self.fix_subject_verb_agreement), |
| ('fix_kana_and_inna', self.fix_kana_and_inna), |
| ('fix_conditional_sentences', self.fix_conditional_sentences), |
| ('fix_tanween_fathah', self.fix_tanween_fathah), |
| ('fix_initial_hamza', self.fix_initial_hamza), |
| ('fix_suffix_hallucination', self.fix_suffix_hallucination), |
| ('regex_rules_fallback', self.regex_rules_fallback), |
| ]: |
| try: |
| text = rule_fn(text, original_text) if rule_name == 'fix_suffix_hallucination' else rule_fn(text) |
| except Exception as e: |
| logger.warning(f"[GRAMMAR-RULES] {rule_name} failed: {e}") |
|
|
| text = re.sub(r'\s+', ' ', text).strip() |
| return text |
|
|
| def fix_suffix_hallucination(self, text, original_text): |
| """ |
| Revert grammar hallucination where extra consonants are appended to pronoun suffixes. |
| Example: شجعتهم → شجعتهمت |
| """ |
| orig_words = original_text.split() |
| curr_words = text.split() |
| |
| if len(orig_words) == len(curr_words): |
| for i in range(len(orig_words)): |
| ow = orig_words[i] |
| cw = curr_words[i] |
| |
| if len(cw) == len(ow) + 1 and cw.startswith(ow): |
| added_char = cw[-1] |
| if ow.endswith(('هم', 'هن', 'كم', 'كن', 'ها', 'نا')) and added_char in 'تمةنل': |
| curr_words[i] = ow |
| logger.info(f"[GRAMMAR-RULES] Reverted suffix hallucination: {cw} → {ow}") |
| text = ' '.join(curr_words) |
| return text |
|
|
| def fix_tanween_fathah(self, text): |
| """ |
| Add tanween fathah (ً) to indefinite accusative nouns ending in ا. |
| |
| Arabic rule: Words like جدا, كثيرا, قرارا should be جداً, كثيراً, قراراً. |
| The trailing ا without tanween is a common orthographic error. |
| |
| From legacy AraSpell._normalize_tanween_patterns(): |
| Only apply to words >= 3 chars ending in ا where the ا is NOT part of |
| the root (e.g. NOT ما، إلى، على، أنا، هذا). |
| """ |
| |
| _NO_TANWEEN = { |
| 'ما', 'إذا', 'هذا', 'أنا', 'إلى', 'على', 'حتى', 'متى', 'لما', |
| 'إلا', 'أما', 'كما', 'ربما', 'مهما', |
| 'عندما', 'بينما', 'حينما', 'كلما', |
| } |
| |
| _ALWAYS_TANWEEN = { |
| 'جدا': 'جداً', |
| 'كثيرا': 'كثيراً', |
| 'شكرا': 'شكراً', |
| 'نظرا': 'نظراً', |
| 'قليلا': 'قليلاً', |
| 'أيضا': 'أيضاً', |
| 'فورا': 'فوراً', |
| 'سابقا': 'سابقاً', |
| 'لاحقا': 'لاحقاً', |
| 'حاليا': 'حالياً', |
| 'تقريبا': 'تقريباً', |
| 'خصوصا': 'خصوصاً', |
| 'عموما': 'عموماً', |
| 'دائما': 'دائماً', |
| 'مباشرا': 'مباشراً', |
| 'أبدا': 'أبداً', |
| 'غالبا': 'غالباً', |
| 'أحيانا': 'أحياناً', |
| 'مثلا': 'مثلاً', |
| 'قرارا': 'قراراً', |
| 'جديدا': 'جديداً', |
| 'كبيرا': 'كبيراً', |
| 'صغيرا': 'صغيراً', |
| 'طويلا': 'طويلاً', |
| 'قصيرا': 'قصيراً', |
| 'سريعا': 'سريعاً', |
| 'بطيئا': 'بطيئاً', |
| 'جيدا': 'جيداً', |
| 'سيئا': 'سيئاً', |
| 'عظيما': 'عظيماً', |
| 'قويا': 'قوياً', |
| 'ضعيفا': 'ضعيفاً', |
| 'صعبا': 'صعباً', |
| 'سهلا': 'سهلاً', |
| 'هاما': 'هاماً', |
| 'نهائيا': 'نهائياً', |
| 'رسميا': 'رسمياً', |
| 'تماما': 'تماماً', |
| 'عاجلا': 'عاجلاً', |
| 'أولا': 'أولاً', |
| 'ثانيا': 'ثانياً', |
| 'ثالثا': 'ثالثاً', |
| 'أخيرا': 'أخيراً', |
| 'حقا': 'حقاً', |
| 'حقيقيا': 'حقيقياً', |
| 'علميا': 'علمياً', |
| 'عمليا': 'عملياً', |
| } |
| words = text.split() |
| for i, w in enumerate(words): |
| clean_w = w.rstrip('.,،؛;:!؟?()[]{}«»"\'…') |
| if clean_w in _ALWAYS_TANWEEN: |
| words[i] = _ALWAYS_TANWEEN[clean_w] + w[len(clean_w):] |
| return ' '.join(words) |
|
|
| def fix_initial_hamza(self, text): |
| """ |
| Fix missing hamza on initial alef for common verb/noun patterns. |
| |
| Arabic rule: أفعل-pattern verbs and certain nouns require hamza: |
| - اعلن → أعلن (أَفْعَل form IV verb) |
| - اصدر → أصدر |
| - اسلم → أسلم |
| """ |
| |
| _HAMZA_FIXES = { |
| 'اعلن': 'أعلن', 'اعلنت': 'أعلنت', 'اعلنوا': 'أعلنوا', |
| 'اصدر': 'أصدر', 'اصدرت': 'أصدرت', 'اصدروا': 'أصدروا', |
| 'اسلم': 'أسلم', 'اسلمت': 'أسلمت', 'اسلموا': 'أسلموا', |
| 'اكد': 'أكد', 'اكدت': 'أكدت', 'اكدوا': 'أكدوا', |
| 'اعطى': 'أعطى', 'اعطت': 'أعطت', 'اعطوا': 'أعطوا', |
| 'انجز': 'أنجز', 'انجزت': 'أنجزت', 'انجزوا': 'أنجزوا', |
| 'ارسل': 'أرسل', 'ارسلت': 'أرسلت', 'ارسلوا': 'أرسلوا', |
| 'اخرج': 'أخرج', 'اخرجت': 'أخرجت', 'اخرجوا': 'أخرجوا', |
| 'انشأ': 'أنشأ', 'انشأت': 'أنشأت', 'انشأوا': 'أنشأوا', |
| 'اضاف': 'أضاف', 'اضافت': 'أضافت', 'اضافوا': 'أضافوا', |
| 'احب': 'أحب', 'احبت': 'أحبت', 'احبوا': 'أحبوا', |
| 'افهم': 'أفهم', 'افهمت': 'أفهمت', 'افهموا': 'أفهموا', |
| 'اعجب': 'أعجب', 'اعجبت': 'أعجبت', 'اعجبوا': 'أعجبوا', |
| 'اكرم': 'أكرم', 'اكرمت': 'أكرمت', 'اكرموا': 'أكرموا', |
| 'انقذ': 'أنقذ', 'انقذت': 'أنقذت', 'انقذوا': 'أنقذوا', |
| 'الامهات': 'الأمهات', 'الاطفال': 'الأطفال', |
| 'الامة': 'الأمة', 'الاستاذ': 'الأستاذ', |
| 'ايضا': 'أيضا', |
| 'اول': 'أول', |
| } |
| |
| _INNA_SENTENCE_INITIAL = { |
| 'ان': 'إن', 'انه': 'إنه', 'انها': 'إنها', |
| 'اننا': 'إننا', 'انهم': 'إنهم', 'انك': 'إنك', 'انكم': 'إنكم', |
| } |
| _ANNA_MID_SENTENCE = { |
| 'ان': 'أن', 'انه': 'أنه', 'انها': 'أنها', |
| 'اننا': 'أننا', 'انهم': 'أنهم', 'انك': 'أنك', 'انكم': 'أنكم', |
| } |
| _HAMZA_STEMS = { |
| 'احب': 'أحب', 'افهم': 'أفهم', 'اعلن': 'أعلن', |
| 'اصدر': 'أصدر', 'اسلم': 'أسلم', 'اكد': 'أكد', |
| 'انجز': 'أنجز', 'ارسل': 'أرسل', 'اخرج': 'أخرج', |
| 'اضاف': 'أضاف', 'اعجب': 'أعجب', 'اكرم': 'أكرم', |
| 'انقذ': 'أنقذ', |
| } |
| _PRONOUN_SUFFIXES = {'ه', 'ها', 'ك', 'كم', 'كن', 'هم', 'هن', 'ني', 'نا'} |
| words = text.split() |
| for i, w in enumerate(words): |
| clean_w = w.rstrip('.,،؛;:!؟?()[]{}«»"\'…') |
| if clean_w in _HAMZA_FIXES: |
| words[i] = _HAMZA_FIXES[clean_w] + w[len(clean_w):] |
| continue |
| |
| _is_sent_start = (i == 0) or (words[i-1][-1] in '.؟!؛' if words[i-1] else False) |
| if _is_sent_start and clean_w in _INNA_SENTENCE_INITIAL: |
| words[i] = _INNA_SENTENCE_INITIAL[clean_w] + w[len(clean_w):] |
| continue |
| if not _is_sent_start and clean_w in _ANNA_MID_SENTENCE: |
| if clean_w == 'ان': |
| |
| if i > 0 and words[i-1].rstrip('.,،؛;:!؟?()[]{}«»"\'…') in ('قال', 'قالت', 'يقول', 'يقولون', 'قلت', 'قلنا', 'تقول', 'يقولوا'): |
| words[i] = 'إن' + w[len(clean_w):] |
| else: |
| pass |
| else: |
| if i > 0 and words[i-1].rstrip('.,،؛;:!؟?()[]{}«»"\'…') in ('قال', 'قالت', 'يقول', 'يقولون', 'قلت', 'قلنا', 'تقول', 'يقولوا'): |
| words[i] = _INNA_SENTENCE_INITIAL[clean_w] + w[len(clean_w):] |
| else: |
| words[i] = _ANNA_MID_SENTENCE[clean_w] + w[len(clean_w):] |
| continue |
| for stem, fixed in _HAMZA_STEMS.items(): |
| if w.startswith(stem) and len(w) > len(stem): |
| suffix = w[len(stem):] |
| if suffix in _PRONOUN_SUFFIXES: |
| words[i] = fixed + suffix |
| break |
| return ' '.join(words) |
|
|