Bayan AI commited on
Commit
ebd15e7
·
1 Parent(s): 76c4caf

refactor: simplify architecture and replace whitelists with CamelTools

Browse files
src/app.py CHANGED
@@ -81,6 +81,8 @@ logging.basicConfig(
81
  )
82
  logger = logging.getLogger(__name__)
83
 
 
 
84
  # Initialize Flask app
85
  app = Flask(__name__, static_folder='.', static_url_path='')
86
  CORS(app, resources={r"/api/*": {"origins": "*"}}) # CORS for API routes only
@@ -1975,31 +1977,6 @@ def analyze_text():
1975
  except Exception:
1976
  pass # Bidirectional check is optional
1977
 
1978
- # ── Phase 12 (A6): Safety Net — Raw Model Fallback ──
1979
- # If raw model output has fewer OOV words, prefer it.
1980
- try:
1981
- _raw_oov = spell_checker.vocab_manager.count_oov_words(raw_corrected)
1982
- _our_oov = spell_checker.vocab_manager.count_oov_words(safe_text)
1983
- if _raw_oov == 0 and _our_oov > 0:
1984
- logger.info(
1985
- f"[SPELLING] Safety net: raw=0 OOV, ours={_our_oov} OOV "
1986
- f"— using raw model output"
1987
- )
1988
- safe_text = raw_corrected
1989
- elif _raw_oov == 0 and _our_oov == 0:
1990
- # Both all-IV but raw is closer to input → prefer raw
1991
- _raw_dist = _levenshtein(current_text, raw_corrected)
1992
- _our_dist = _levenshtein(current_text, safe_text)
1993
- _rvr_dist = _levenshtein(safe_text, raw_corrected)
1994
- if _raw_dist < _our_dist and _rvr_dist <= 3:
1995
- logger.info(
1996
- f"[SPELLING] Safety net: raw closer to input "
1997
- f"(raw_dist={_raw_dist}, our_dist={_our_dist})"
1998
- )
1999
- safe_text = raw_corrected
2000
- except Exception:
2001
- pass # Safety net is optional
2002
-
2003
  ctx.mutate_text(safe_text, OffsetMapper)
2004
  current_text = ctx.current_text
2005
  except Exception as e:
@@ -2323,6 +2300,12 @@ def analyze_text():
2323
  _o_cl = orig_text.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
2324
  _c_cl = corr_text.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
2325
 
 
 
 
 
 
 
2326
  # Case: ون/ان → ين (sound masculine plural / dual case change)
2327
  if (_o_cl.endswith('ون') and _c_cl.endswith('ين') and _o_cl[:-2] == _c_cl[:-2]):
2328
  _is_grammar_pattern = True
@@ -2412,9 +2395,13 @@ def analyze_text():
2412
  # Catches: القانون→القانين, يعزف→يعزفون, للإنسان→للإنسين
2413
  if not _is_grammar_pattern and orig_text and corr_text and len(orig_text) > 2:
2414
  import re as _re_jac
2415
- # Strip punctuation/spaces for comparison
2416
- _o_chars = set(_re_jac.sub(r'[\s.,،؛؟!:;?]', '', orig_text))
2417
- _c_chars = set(_re_jac.sub(r'[\s.,،؛؟!:;?]', '', corr_text))
 
 
 
 
2418
  if _o_chars and _c_chars:
2419
  _jac = len(_o_chars & _c_chars) / len(_o_chars | _c_chars)
2420
  if _jac < 0.5:
@@ -2451,8 +2438,11 @@ def analyze_text():
2451
  _gram_dir_blocked = True
2452
  break
2453
  if _gram_dir_blocked:
2454
- continue
2455
-
 
 
 
2456
 
2457
  # FIX-22: Protect tanween (preserve ً ٌ ٍ from original)
2458
  _TANWEEN_CHARS = set('ًٌٍ')
@@ -2508,64 +2498,6 @@ def analyze_text():
2508
  logger.error(traceback.format_exc())
2509
  timing_ms['grammar_error'] = f"{type(e).__name__}: {str(e)[:200]}"
2510
 
2511
- # ── FIX-48v3: ه→ة pass AFTER grammar (whitelist-based) ──
2512
- # Must run AFTER grammar so grammar model can use ه for gender decisions.
2513
- # Uses a whitelist of common words that are frequently written with ه instead of ة.
2514
- if not _is_religious_text:
2515
- try:
2516
- _HATA_WHITELIST = {
2517
- # Common nouns — definite form (with ال)
2518
- 'الحكومه': 'الحكومة', 'المدرسه': 'المدرسة', 'الشركه': 'الشركة',
2519
- 'الجامعه': 'الجامعة', 'المدينه': 'المدينة', 'القصه': 'القصة',
2520
- 'المكتبه': 'المكتبة', 'الطائره': 'الطائرة', 'الوزاره': 'الوزارة',
2521
- 'المديره': 'المديرة', 'المعلمه': 'المعلمة', 'الطالبه': 'الطالبة',
2522
- 'القريه': 'القرية', 'الحديقه': 'الحديقة', 'المحكمه': 'المحكمة',
2523
- 'الكنيسه': 'الكنيسة', 'المنطقه': 'المنطقة', 'الدوله': 'الدولة',
2524
- 'السياره': 'السيارة', 'الطاوله': 'الطاولة', 'الغرفه': 'الغرفة',
2525
- 'المحطه': 'المحطة', 'السفاره': 'السفارة', 'الوظيفه': 'الوظيفة',
2526
- 'الصحيفه': 'الصحيفة', 'العائله': 'العائلة', 'الحياه': 'الحياة',
2527
- 'الصلاه': 'الصلاة', 'الزكاه': 'الزكاة',
2528
- # Common nouns — indefinite form
2529
- 'حكومه': 'حكومة', 'مدرسه': 'مدرسة', 'شركه': 'شركة',
2530
- 'جامعه': 'جامعة', 'مدينه': 'مدينة', 'قصه': 'قصة',
2531
- 'مكتبه': 'مكتبة', 'طائره': 'طائرة', 'وزاره': 'وزارة',
2532
- 'مديره': 'مديرة', 'معلمه': 'معلمة', 'طالبه': 'طالبة',
2533
- 'قريه': 'قرية', 'حديقه': 'حديقة', 'محكمه': 'محكمة',
2534
- 'منطقه': 'منطقة', 'دوله': 'دولة', 'سياره': 'سيارة',
2535
- 'غرفه': 'غرفة', 'محطه': 'محطة', 'وظيفه': 'وظيفة',
2536
- 'عائله': 'عائلة', 'حياه': 'حياة', 'صلاه': 'صلاة',
2537
- # Common adjectives — feminine
2538
- 'كبيره': 'كبيرة', 'صغيره': 'صغيرة', 'جميله': 'جميلة',
2539
- 'طويله': 'طويلة', 'قصيره': 'قصيرة', 'جديده': 'جديدة',
2540
- 'قديمه': 'قديمة', 'سريعه': 'سريعة', 'بطيئه': 'بطيئة',
2541
- }
2542
- _hata_text = ctx.current_text
2543
- _hata_words = _hata_text.split()
2544
- _hata_changed = False
2545
- _hata_result = []
2546
- _hata_pos = 0 # track position in text for patch offsets
2547
- for _hw in _hata_words:
2548
- _hw_clean = _hw.rstrip('.،؛؟!?!')
2549
- if _hw_clean in _HATA_WHITELIST:
2550
- _punct_suffix = _hw[len(_hw_clean):]
2551
- _fixed = _HATA_WHITELIST[_hw_clean]
2552
- logger.info(f"[HA-TA] Post-grammar ه→ة: '{_hw}'→'{_fixed}{_punct_suffix}'")
2553
- _hata_result.append(_fixed + _punct_suffix)
2554
- _hata_changed = True
2555
- # Create a patch so the final output includes this fix
2556
- ctx.add_patch(
2557
- 'spelling', _hata_pos, _hata_pos + len(_hw),
2558
- _fixed + _punct_suffix, confidence=0.85,
2559
- )
2560
- else:
2561
- _hata_result.append(_hw)
2562
- _hata_pos += len(_hw) + 1 # +1 for space
2563
- if _hata_changed:
2564
- _hata_new = ' '.join(_hata_result)
2565
- ctx.mutate_text(_hata_new, OffsetMapper)
2566
- current_text = ctx.current_text
2567
- except Exception as e:
2568
- logger.warning(f"[HA-TA] Failed: {type(e).__name__}: {e}")
2569
 
2570
  # 3. Punctuation (runs on grammar-corrected text — PuncAra-v1 local model)
2571
  # FIX-07: Skip punctuation for religious text
 
81
  )
82
  logger = logging.getLogger(__name__)
83
 
84
+ DEBUG_TRACE = True # Toggleable trace logging
85
+
86
  # Initialize Flask app
87
  app = Flask(__name__, static_folder='.', static_url_path='')
88
  CORS(app, resources={r"/api/*": {"origins": "*"}}) # CORS for API routes only
 
1977
  except Exception:
1978
  pass # Bidirectional check is optional
1979
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1980
  ctx.mutate_text(safe_text, OffsetMapper)
1981
  current_text = ctx.current_text
1982
  except Exception as e:
 
2300
  _o_cl = orig_text.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
2301
  _c_cl = corr_text.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
2302
 
2303
+ # Priority 4: Diacritic-Normalized Grammar Validation
2304
+ import re as _re_diac
2305
+ _o_cl = _re_diac.sub(r'[\u064B-\u065F\u0670]', '', _o_cl)
2306
+ _c_cl = _re_diac.sub(r'[\u064B-\u065F\u0670]', '', _c_cl)
2307
+
2308
+
2309
  # Case: ون/ان → ين (sound masculine plural / dual case change)
2310
  if (_o_cl.endswith('ون') and _c_cl.endswith('ين') and _o_cl[:-2] == _c_cl[:-2]):
2311
  _is_grammar_pattern = True
 
2395
  # Catches: القانون→القانين, يعزف→يعزفون, للإنسان→للإنسين
2396
  if not _is_grammar_pattern and orig_text and corr_text and len(orig_text) > 2:
2397
  import re as _re_jac
2398
+ # Strip punctuation/spaces and normalize Alif/Hamza for comparison
2399
+ _o_norm = _re_jac.sub(r'[\s.,،؛؟!:;?]', '', orig_text)
2400
+ _c_norm = _re_jac.sub(r'[\s.,،؛؟!:;?]', '', corr_text)
2401
+ _o_norm = _re_jac.sub(r'[أإآ]', 'ا', _o_norm)
2402
+ _c_norm = _re_jac.sub(r'[أإآ]', 'ا', _c_norm)
2403
+ _o_chars = set(_o_norm)
2404
+ _c_chars = set(_c_norm)
2405
  if _o_chars and _c_chars:
2406
  _jac = len(_o_chars & _c_chars) / len(_o_chars | _c_chars)
2407
  if _jac < 0.5:
 
2438
  _gram_dir_blocked = True
2439
  break
2440
  if _gram_dir_blocked:
2441
+ logger.error(traceback.format_exc())
2442
+ continue
2443
+ # DEBUG_TRACE
2444
+ if _is_grammar_pattern:
2445
+ logger.debug(f"[DEBUG_TRACE] Pattern match found for: '{orig_text}'→'{corr_text}'")
2446
 
2447
  # FIX-22: Protect tanween (preserve ً ٌ ٍ from original)
2448
  _TANWEEN_CHARS = set('ًٌٍ')
 
2498
  logger.error(traceback.format_exc())
2499
  timing_ms['grammar_error'] = f"{type(e).__name__}: {str(e)[:200]}"
2500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2501
 
2502
  # 3. Punctuation (runs on grammar-corrected text — PuncAra-v1 local model)
2503
  # FIX-07: Skip punctuation for religious text
src/nlp/grammar/grammar_rules.py CHANGED
@@ -203,9 +203,6 @@ class ArabicGrammarGuard:
203
  return " ".join(corrected_tokens)
204
 
205
  def fix_gender_agreement(self, text):
206
- text = re.sub(r'\bهذان\s+(ال[أ-ي]+تان)\b', r'هاتان \1', text)
207
- text = re.sub(r'\bهاتان\s+(ال[أ-ي]+[^ت]ان)\b', r'هذان \1', text)
208
- text = re.sub(r'\bهذهن\b', 'هاتان', text)
209
 
210
  text = re.sub(r'\bأحد عشر\s+([أ-ي]+ة)\b', r'إحدى عشرة \1', text)
211
  text = re.sub(r'\bأحد عشرة\s+([أ-ي]+ة)\b', r'إحدى عشرة \1', text)
 
203
  return " ".join(corrected_tokens)
204
 
205
  def fix_gender_agreement(self, text):
 
 
 
206
 
207
  text = re.sub(r'\bأحد عشر\s+([أ-ي]+ة)\b', r'إحدى عشرة \1', text)
208
  text = re.sub(r'\bأحد عشرة\s+([أ-ي]+ة)\b', r'إحدى عشرة \1', text)
src/nlp/punctuation/spelling/araspell_rules.py CHANGED
@@ -660,49 +660,21 @@ class OutputValidator:
660
  # ═══════════════════════════════════════════════════════════════════════════════
661
 
662
  class VocabularyManager:
663
- """Centralized vocabulary management for OOV/IV detection."""
664
-
665
- HAMZA_VARIANTS = {'أ', 'إ', 'آ', 'ء', 'ؤ', 'ئ', 'ا'}
666
- ALEF_NORMALIZED = 'ا'
667
- TA_MARBUTA = 'ة'
668
- HA = 'ه'
669
- YA_VARIANTS = {'ي', 'ى'}
670
- YA_NORMALIZED = 'ي'
671
 
672
  def __init__(self, tokenizer):
673
  self.tokenizer = tokenizer
674
- self.vocab = {
675
- w for w in tokenizer.get_vocab().keys()
676
- if w.isalpha() and not w.startswith('##') and len(w) > 1
677
- }
678
- self.vocab_rank = {w: i for w, i in tokenizer.get_vocab().items()}
679
- self.normalized_vocab = {self.normalize_for_comparison(w): w for w in self.vocab}
680
- logger.info(f"VocabularyManager initialized: {len(self.vocab)} words")
681
-
682
- @classmethod
683
- def normalize_for_comparison(cls, word: str) -> str:
684
- result = []
685
- for i, char in enumerate(word):
686
- if char in cls.HAMZA_VARIANTS:
687
- result.append(cls.ALEF_NORMALIZED)
688
- elif char == cls.TA_MARBUTA and i == len(word) - 1:
689
- result.append(cls.HA)
690
- elif char in cls.YA_VARIANTS:
691
- result.append(cls.YA_NORMALIZED)
692
- else:
693
- result.append(char)
694
- return ''.join(result)
695
 
696
  def is_iv(self, word: str) -> bool:
697
  clean = re.sub(r'[^\w]', '', word)
698
  if not clean:
699
  return True
700
- if clean in self.vocab:
701
- return True
702
- normalized = self.normalize_for_comparison(clean)
703
- if normalized in self.normalized_vocab:
704
- return True
705
- return False
706
 
707
  def is_oov(self, word: str) -> bool:
708
  return not self.is_iv(word)
 
660
  # ═══════════════════════════════════════════════════════════════════════════════
661
 
662
  class VocabularyManager:
663
+ """Centralized vocabulary management for OOV/IV detection using CamelTools."""
 
 
 
 
 
 
 
664
 
665
  def __init__(self, tokenizer):
666
  self.tokenizer = tokenizer
667
+ from camel_tools.morphology.database import MorphologyDB
668
+ from camel_tools.morphology.analyzer import Analyzer
669
+ self._db = MorphologyDB.builtin_db()
670
+ self.analyzer = Analyzer(self._db)
671
+ logger.info("VocabularyManager initialized with CamelTools Analyzer")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
672
 
673
  def is_iv(self, word: str) -> bool:
674
  clean = re.sub(r'[^\w]', '', word)
675
  if not clean:
676
  return True
677
+ return len(self.analyzer.analyze(clean)) > 0
 
 
 
 
 
678
 
679
  def is_oov(self, word: str) -> bool:
680
  return not self.is_iv(word)
src/nlp/spelling/araspell_rules.py CHANGED
@@ -674,49 +674,21 @@ class OutputValidator:
674
  # ═══════════════════════════════════════════════════════════════════════════════
675
 
676
  class VocabularyManager:
677
- """Centralized vocabulary management for OOV/IV detection."""
678
-
679
- HAMZA_VARIANTS = {'أ', 'إ', 'آ', 'ء', 'ؤ', 'ئ', 'ا'}
680
- ALEF_NORMALIZED = 'ا'
681
- TA_MARBUTA = 'ة'
682
- HA = 'ه'
683
- YA_VARIANTS = {'ي', 'ى'}
684
- YA_NORMALIZED = 'ي'
685
 
686
  def __init__(self, tokenizer):
687
  self.tokenizer = tokenizer
688
- self.vocab = {
689
- w for w in tokenizer.get_vocab().keys()
690
- if w.isalpha() and not w.startswith('##') and len(w) > 1
691
- }
692
- self.vocab_rank = {w: i for w, i in tokenizer.get_vocab().items()}
693
- self.normalized_vocab = {self.normalize_for_comparison(w): w for w in self.vocab}
694
- logger.info(f"VocabularyManager initialized: {len(self.vocab)} words")
695
-
696
- @classmethod
697
- def normalize_for_comparison(cls, word: str) -> str:
698
- result = []
699
- for i, char in enumerate(word):
700
- if char in cls.HAMZA_VARIANTS:
701
- result.append(cls.ALEF_NORMALIZED)
702
- elif char == cls.TA_MARBUTA and i == len(word) - 1:
703
- result.append(cls.HA)
704
- elif char in cls.YA_VARIANTS:
705
- result.append(cls.YA_NORMALIZED)
706
- else:
707
- result.append(char)
708
- return ''.join(result)
709
 
710
  def is_iv(self, word: str) -> bool:
711
  clean = re.sub(r'[^\w]', '', word)
712
  if not clean:
713
  return True
714
- if clean in self.vocab:
715
- return True
716
- normalized = self.normalize_for_comparison(clean)
717
- if normalized in self.normalized_vocab:
718
- return True
719
- return False
720
 
721
  def is_oov(self, word: str) -> bool:
722
  return not self.is_iv(word)
 
674
  # ═══════════════════════════════════════════════════════════════════════════════
675
 
676
  class VocabularyManager:
677
+ """Centralized vocabulary management for OOV/IV detection using CamelTools."""
 
 
 
 
 
 
 
678
 
679
  def __init__(self, tokenizer):
680
  self.tokenizer = tokenizer
681
+ from camel_tools.morphology.database import MorphologyDB
682
+ from camel_tools.morphology.analyzer import Analyzer
683
+ self._db = MorphologyDB.builtin_db()
684
+ self.analyzer = Analyzer(self._db)
685
+ logger.info("VocabularyManager initialized with CamelTools Analyzer")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
686
 
687
  def is_iv(self, word: str) -> bool:
688
  clean = re.sub(r'[^\w]', '', word)
689
  if not clean:
690
  return True
691
+ return len(self.analyzer.analyze(clean)) > 0
 
 
 
 
 
692
 
693
  def is_oov(self, word: str) -> bool:
694
  return not self.is_iv(word)