Commit ·
df5e501
1
Parent(s): 5ed1cab
Fix collision benchmark failures: patch overlap mapping, tokenization guard, and rule hallucinations
Browse files- src/app.py +19 -5
- src/nlp/grammar/grammar_rules.py +23 -1
- src/nlp/pipeline_context.py +2 -2
- src/nlp/punctuation/punctuation_rules.py +2 -2
- src/nlp/spelling/araspell_rules.py +35 -4
src/app.py
CHANGED
|
@@ -693,7 +693,7 @@ class OffsetMapper:
|
|
| 693 |
for tag, i1, i2, j1, j2 in s.get_opcodes():
|
| 694 |
self._opcodes.append((i1, i2, j1, j2))
|
| 695 |
|
| 696 |
-
def reverse_map_offset(self, pos_in_after):
|
| 697 |
"""
|
| 698 |
Map a single position from text_after → text_before.
|
| 699 |
(CURRENT_TEXT after mutation → CURRENT_TEXT before mutation)
|
|
@@ -701,13 +701,27 @@ class OffsetMapper:
|
|
| 701 |
Used by PipelineContext.map_to_original() to walk the mapper
|
| 702 |
chain in reverse, ultimately reaching ORIGINAL_TEXT coordinates.
|
| 703 |
"""
|
|
|
|
| 704 |
for i1, i2, j1, j2 in self._opcodes:
|
| 705 |
if j1 <= pos_in_after <= j2:
|
| 706 |
-
|
| 707 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
ratio = (pos_in_after - j1) / (j2 - j1)
|
| 709 |
-
|
| 710 |
-
|
|
|
|
|
|
|
|
|
|
| 711 |
|
| 712 |
def forward_map_range(self, start_in_before, end_in_before):
|
| 713 |
"""
|
|
|
|
| 693 |
for tag, i1, i2, j1, j2 in s.get_opcodes():
|
| 694 |
self._opcodes.append((i1, i2, j1, j2))
|
| 695 |
|
| 696 |
+
def reverse_map_offset(self, pos_in_after, is_end=False):
|
| 697 |
"""
|
| 698 |
Map a single position from text_after → text_before.
|
| 699 |
(CURRENT_TEXT after mutation → CURRENT_TEXT before mutation)
|
|
|
|
| 701 |
Used by PipelineContext.map_to_original() to walk the mapper
|
| 702 |
chain in reverse, ultimately reaching ORIGINAL_TEXT coordinates.
|
| 703 |
"""
|
| 704 |
+
matches = []
|
| 705 |
for i1, i2, j1, j2 in self._opcodes:
|
| 706 |
if j1 <= pos_in_after <= j2:
|
| 707 |
+
matches.append((i1, i2, j1, j2))
|
| 708 |
+
|
| 709 |
+
if not matches:
|
| 710 |
+
return len(self._text_before)
|
| 711 |
+
|
| 712 |
+
mapped_positions = []
|
| 713 |
+
for i1, i2, j1, j2 in matches:
|
| 714 |
+
if j2 == j1: # insertion point in text_before (deleted in text_after)
|
| 715 |
+
# If we're mapping an 'end' coordinate, we want to encompass the deleted text (i2).
|
| 716 |
+
# If we're mapping a 'start' coordinate, we want the start of the deletion (i1).
|
| 717 |
+
mapped_positions.append(i2 if is_end else i1)
|
| 718 |
+
else:
|
| 719 |
ratio = (pos_in_after - j1) / (j2 - j1)
|
| 720 |
+
mapped_positions.append(round(i1 + ratio * (i2 - i1)))
|
| 721 |
+
|
| 722 |
+
# If is_end is True, maximize the mapped offset (include as much as possible)
|
| 723 |
+
# If is_end is False, minimize the mapped offset
|
| 724 |
+
return max(mapped_positions) if is_end else min(mapped_positions)
|
| 725 |
|
| 726 |
def forward_map_range(self, start_in_before, end_in_before):
|
| 727 |
"""
|
src/nlp/grammar/grammar_rules.py
CHANGED
|
@@ -765,16 +765,38 @@ class ArabicGrammarGuard:
|
|
| 765 |
('fix_conditional_sentences', self.fix_conditional_sentences),
|
| 766 |
('fix_tanween_fathah', self.fix_tanween_fathah),
|
| 767 |
('fix_initial_hamza', self.fix_initial_hamza),
|
|
|
|
| 768 |
('regex_rules_fallback', self.regex_rules_fallback),
|
| 769 |
]:
|
| 770 |
try:
|
| 771 |
-
text = rule_fn(text)
|
| 772 |
except Exception as e:
|
| 773 |
logger.warning(f"[GRAMMAR-RULES] {rule_name} failed: {e}")
|
| 774 |
|
| 775 |
text = re.sub(r'\s+', ' ', text).strip()
|
| 776 |
return text
|
| 777 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
def fix_tanween_fathah(self, text):
|
| 779 |
"""
|
| 780 |
Add tanween fathah (ً) to indefinite accusative nouns ending in ا.
|
|
|
|
| 765 |
('fix_conditional_sentences', self.fix_conditional_sentences),
|
| 766 |
('fix_tanween_fathah', self.fix_tanween_fathah),
|
| 767 |
('fix_initial_hamza', self.fix_initial_hamza),
|
| 768 |
+
('fix_suffix_hallucination', self.fix_suffix_hallucination),
|
| 769 |
('regex_rules_fallback', self.regex_rules_fallback),
|
| 770 |
]:
|
| 771 |
try:
|
| 772 |
+
text = rule_fn(text, original_text) if rule_name == 'fix_suffix_hallucination' else rule_fn(text)
|
| 773 |
except Exception as e:
|
| 774 |
logger.warning(f"[GRAMMAR-RULES] {rule_name} failed: {e}")
|
| 775 |
|
| 776 |
text = re.sub(r'\s+', ' ', text).strip()
|
| 777 |
return text
|
| 778 |
|
| 779 |
+
def fix_suffix_hallucination(self, text, original_text):
|
| 780 |
+
"""
|
| 781 |
+
Revert grammar hallucination where extra consonants are appended to pronoun suffixes.
|
| 782 |
+
Example: شجعتهم → شجعتهمت
|
| 783 |
+
"""
|
| 784 |
+
orig_words = original_text.split()
|
| 785 |
+
curr_words = text.split()
|
| 786 |
+
|
| 787 |
+
if len(orig_words) == len(curr_words):
|
| 788 |
+
for i in range(len(orig_words)):
|
| 789 |
+
ow = orig_words[i]
|
| 790 |
+
cw = curr_words[i]
|
| 791 |
+
# If the current word is just the original word + 1 consonant, and original ended in a suffix
|
| 792 |
+
if len(cw) == len(ow) + 1 and cw.startswith(ow):
|
| 793 |
+
added_char = cw[-1]
|
| 794 |
+
if ow.endswith(('هم', 'هن', 'كم', 'كن', 'ها', 'نا')) and added_char in 'تمةنل':
|
| 795 |
+
curr_words[i] = ow
|
| 796 |
+
logger.info(f"[GRAMMAR-RULES] Reverted suffix hallucination: {cw} → {ow}")
|
| 797 |
+
text = ' '.join(curr_words)
|
| 798 |
+
return text
|
| 799 |
+
|
| 800 |
def fix_tanween_fathah(self, text):
|
| 801 |
"""
|
| 802 |
Add tanween fathah (ً) to indefinite accusative nouns ending in ا.
|
src/nlp/pipeline_context.py
CHANGED
|
@@ -48,8 +48,8 @@ class PipelineContext:
|
|
| 48 |
"""
|
| 49 |
curr_start, curr_end = start, end
|
| 50 |
for mapper in reversed(self._offset_mappers):
|
| 51 |
-
curr_start = mapper.reverse_map_offset(curr_start)
|
| 52 |
-
curr_end = mapper.reverse_map_offset(curr_end)
|
| 53 |
return curr_start, curr_end
|
| 54 |
|
| 55 |
def add_patch(self, stage: str, start_current: int, end_current: int,
|
|
|
|
| 48 |
"""
|
| 49 |
curr_start, curr_end = start, end
|
| 50 |
for mapper in reversed(self._offset_mappers):
|
| 51 |
+
curr_start = mapper.reverse_map_offset(curr_start, is_end=False)
|
| 52 |
+
curr_end = mapper.reverse_map_offset(curr_end, is_end=True)
|
| 53 |
return curr_start, curr_end
|
| 54 |
|
| 55 |
def add_patch(self, stage: str, start_current: int, end_current: int,
|
src/nlp/punctuation/punctuation_rules.py
CHANGED
|
@@ -67,9 +67,9 @@ def arabic_postprocessing(text: str) -> str:
|
|
| 67 |
if re.fullmatch(_ALLOWED_COLON_CUES, prev_word):
|
| 68 |
return match.group(0)
|
| 69 |
# If it's a definite noun (starts with ال) and not in allowed list, it's hallucinated.
|
| 70 |
-
# e.g.
|
| 71 |
if prev_word.startswith('ال'):
|
| 72 |
-
return f'{prev_word}
|
| 73 |
return match.group(0)
|
| 74 |
|
| 75 |
text = re.sub(r'([\u0600-\u06FF]+)(\s*:)', _colon_guard, text)
|
|
|
|
| 67 |
if re.fullmatch(_ALLOWED_COLON_CUES, prev_word):
|
| 68 |
return match.group(0)
|
| 69 |
# If it's a definite noun (starts with ال) and not in allowed list, it's hallucinated.
|
| 70 |
+
# Remove the colon entirely — replacing with comma is also wrong (e.g. الشمس، مشرقة)
|
| 71 |
if prev_word.startswith('ال'):
|
| 72 |
+
return f'{prev_word}'
|
| 73 |
return match.group(0)
|
| 74 |
|
| 75 |
text = re.sub(r'([\u0600-\u06FF]+)(\s*:)', _colon_guard, text)
|
src/nlp/spelling/araspell_rules.py
CHANGED
|
@@ -247,10 +247,13 @@ class AraSpellPostProcessor:
|
|
| 247 |
'اقرا': 'أقرأ', 'اقرأ': 'أقرأ',
|
| 248 |
'اعمل': 'أعمل', 'ادرس': 'أدرس',
|
| 249 |
'اشتري': 'أشتري', 'اسافر': 'أسافر',
|
|
|
|
| 250 |
'مسؤول': 'مسؤول', 'مسؤولية': 'مسؤولية',
|
| 251 |
'رؤية': 'رؤية', 'رؤيا': 'رؤيا',
|
| 252 |
'مؤسسة': 'مؤسسة', 'مؤتمر': 'مؤتمر',
|
| 253 |
'تأثير': 'تأثير', 'تأكيد': 'تأكيد',
|
|
|
|
|
|
|
| 254 |
# FIX-14: Alif maqsura common errors
|
| 255 |
'المستشفي': 'المستشفى',
|
| 256 |
'مصطفي': 'مصطفى', 'موسي': 'موسى', 'عيسي': 'عيسى',
|
|
@@ -1618,14 +1621,42 @@ class ArabicSpellChecker:
|
|
| 1618 |
result = AraSpellPostProcessor.fix_ha_ta_marbuta(result, vocab_manager=self.vocab_manager)
|
| 1619 |
|
| 1620 |
# 11. DESTRUCTIVE TOKENIZATION GUARD
|
| 1621 |
-
# Arabic orthography does not use standalone 1-letter words.
|
| 1622 |
-
# If the model creates a standalone 1-letter word that was not in the original,
|
|
|
|
| 1623 |
orig_standalone = set(w for w in original.split() if len(w) == 1)
|
|
|
|
| 1624 |
res_words_list = result.split()
|
| 1625 |
-
for w in res_words_list:
|
| 1626 |
if len(w) == 1 and w not in orig_standalone:
|
| 1627 |
if w in 'واتيبلفك':
|
| 1628 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1629 |
result = original
|
| 1630 |
break
|
| 1631 |
|
|
|
|
| 247 |
'اقرا': 'أقرأ', 'اقرأ': 'أقرأ',
|
| 248 |
'اعمل': 'أعمل', 'ادرس': 'أدرس',
|
| 249 |
'اشتري': 'أشتري', 'اسافر': 'أسافر',
|
| 250 |
+
'احبه': 'أحبه',
|
| 251 |
'مسؤول': 'مسؤول', 'مسؤولية': 'مسؤولية',
|
| 252 |
'رؤية': 'رؤية', 'رؤيا': 'رؤيا',
|
| 253 |
'مؤسسة': 'مؤسسة', 'مؤتمر': 'مؤتمر',
|
| 254 |
'تأثير': 'تأثير', 'تأكيد': 'تأكيد',
|
| 255 |
+
'البنايه': 'البناية',
|
| 256 |
+
'جدا': 'جداً', 'جداً': 'جداً',
|
| 257 |
# FIX-14: Alif maqsura common errors
|
| 258 |
'المستشفي': 'المستشفى',
|
| 259 |
'مصطفي': 'مصطفى', 'موسي': 'موسى', 'عيسي': 'عيسى',
|
|
|
|
| 1621 |
result = AraSpellPostProcessor.fix_ha_ta_marbuta(result, vocab_manager=self.vocab_manager)
|
| 1622 |
|
| 1623 |
# 11. DESTRUCTIVE TOKENIZATION GUARD
|
| 1624 |
+
# Arabic orthography does not use standalone 1-letter words except prepositions.
|
| 1625 |
+
# If the model creates a standalone 1-letter word that was not in the original,
|
| 1626 |
+
# check if it's a legitimate prefix separation (e.g. بالشاروع→ب الشارع).
|
| 1627 |
orig_standalone = set(w for w in original.split() if len(w) == 1)
|
| 1628 |
+
orig_words = original.split()
|
| 1629 |
res_words_list = result.split()
|
| 1630 |
+
for idx, w in enumerate(res_words_list):
|
| 1631 |
if len(w) == 1 and w not in orig_standalone:
|
| 1632 |
if w in 'واتيبلفك':
|
| 1633 |
+
# Check if this is a legitimate prefix separation:
|
| 1634 |
+
# The original word should have started with this letter as a prefix
|
| 1635 |
+
is_prefix_separation = False
|
| 1636 |
+
if w in 'وفبلك' and idx + 1 < len(res_words_list):
|
| 1637 |
+
next_word = res_words_list[idx + 1]
|
| 1638 |
+
combined = w + next_word
|
| 1639 |
+
# If any original word started with the prefix letter and
|
| 1640 |
+
# the remainder matches the next word, it's legitimate
|
| 1641 |
+
for ow in orig_words:
|
| 1642 |
+
if ow.startswith(w) and len(ow) > 2:
|
| 1643 |
+
is_prefix_separation = True
|
| 1644 |
+
break
|
| 1645 |
+
|
| 1646 |
+
if not is_prefix_separation:
|
| 1647 |
+
logger.info(f"[SPELLING] Blocked destructive tokenization (hallucinated standalone '{w}'): '{original}' -> '{result}'")
|
| 1648 |
+
result = original
|
| 1649 |
+
break
|
| 1650 |
+
|
| 1651 |
+
# 12. MORPHOLOGICAL MUTATION GUARD (Verb -> Noun)
|
| 1652 |
+
# Prevents spelling from changing a plural verb (e.g. صممو) to a noun (e.g. مصممو) by prepending م
|
| 1653 |
+
if len(orig_words) == len(res_words_list):
|
| 1654 |
+
for idx in range(len(orig_words)):
|
| 1655 |
+
ow = orig_words[idx]
|
| 1656 |
+
rw = res_words_list[idx]
|
| 1657 |
+
# If the word didn't start with م but the correction does, and it looks like a plural verb
|
| 1658 |
+
if not ow.startswith('م') and rw.startswith('م') and rw[1:] == ow and ow.endswith('و'):
|
| 1659 |
+
logger.info(f"[SPELLING] Blocked morphological mutation (verb→noun '{ow}'→'{rw}'): '{original}' -> '{result}'")
|
| 1660 |
result = original
|
| 1661 |
break
|
| 1662 |
|