Mohamed Atef commited on
Commit
d97d7bd
·
1 Parent(s): b6ffe53

Phase 13: Fix spelling filter blind spots + eager model loading

Browse files

Spelling filter improvements:
- Add adjacent character transposition detection (e.g., العصوبات→الصعوبات)
Transpositions have Levenshtein=2 but are a single adjacent swap.
Accepted with dampened confidence (0.6) when OOV→IV.
- Add single character insertion detection (extra letter typed)
Accepted with confidence 0.7 when OOV→IV.
- Add single character deletion detection (missed key)
Accepted with confidence 0.7 when OOV→IV.
- All three new edit types require OOV→IV validation to prevent
false positives (spelling precision remains 1.000).

Bidirectional fix patch creation:
- The bidirectional validation (Phase 12 A5) was silently correcting
text without creating patches, so users never saw the suggestion.
- Now creates spelling patches when rescuing OOV→IV words, making
corrections visible in the UI.

Eager model loading:
- All NLP models (spelling, grammar, punctuation, autocomplete, dialect)
now load at server startup instead of on first request.
- Eliminates cold-start latency on first user interaction.
- Each model load is independently wrapped in try/except so a single
failure does not prevent the server from starting.

Files changed (1) hide show
  1. src/app.py +176 -6
src/app.py CHANGED
@@ -990,6 +990,91 @@ def _is_small_spelling_change(orig_word, corr_word, vocab_manager=None):
990
  ('د', 'ض'), ('ض', 'د'), # d/emphatic-d
991
  ('غ', 'ق'), ('ق', 'غ'), # gh/q
992
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
993
  # Check every character pair — reject if ANY non-orthographic change
994
  if len(orig_word) != len(corr_word):
995
  # Length change = structural change, not just orthographic
@@ -998,7 +1083,6 @@ def _is_small_spelling_change(orig_word, corr_word, vocab_manager=None):
998
  return 0.0
999
  # ── Phase 12 (A1): Keyboard-neighbor and phonetic acceptance ──
1000
  # Check each differing character: ortho → full accept, keyboard/phonetic → dampened
1001
- from nlp.spelling.araspell_rules import RulesBasedCorrector
1002
  _has_keyboard_or_phonetic = False
1003
  for a, b in zip(orig_word, corr_word):
1004
  if a != b:
@@ -1627,6 +1711,31 @@ def analyze_text():
1627
  f"[SPELLING] Bidirectional fix: "
1628
  f"'{_safe_words[_bi]}'(OOV)→'{_raw_words[_bi]}'(IV)"
1629
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1630
  _safe_words[_bi] = _raw_words[_bi]
1631
  _bidi_changed = True
1632
  if _bidi_changed:
@@ -2201,22 +2310,83 @@ def internal_error(error):
2201
  _models_loaded = False
2202
 
2203
  def _ensure_models_loaded():
 
 
 
 
 
 
2204
  global _models_loaded
2205
  if _models_loaded:
2206
  return
2207
  _models_loaded = True
2208
- logger.info("Loading models (production startup)...")
 
 
 
 
 
 
2209
  if not load_models():
2210
- logger.error("Failed to load any models. Server will start but functionality will be limited.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2211
 
2212
  # Load models on import (gunicorn imports this module, __name__ != '__main__')
2213
  _ensure_models_loaded()
2214
 
2215
 
2216
  if __name__ == '__main__':
2217
- # Load models on startup (development)
2218
- _ensure_models_loaded()
2219
-
2220
  # Run the app
2221
  port = int(os.environ.get('PORT', 5000))
2222
  debug = os.environ.get('DEBUG', 'False').lower() == 'true'
 
990
  ('د', 'ض'), ('ض', 'د'), # d/emphatic-d
991
  ('غ', 'ق'), ('ق', 'غ'), # gh/q
992
  }
993
+
994
+ from nlp.spelling.araspell_rules import RulesBasedCorrector
995
+
996
+ # ── Phase 13: Adjacent character transposition detection ──
997
+ # Transpositions (e.g., العصوبات→الصعوبات) have Levenshtein=2 but are a
998
+ # single adjacent swap. Detect and accept when OOV→IV.
999
+ if len(orig_word) == len(corr_word) and dist == 2:
1000
+ _transposition_found = False
1001
+ for _ti in range(len(orig_word) - 1):
1002
+ if (orig_word[_ti] == corr_word[_ti + 1] and
1003
+ orig_word[_ti + 1] == corr_word[_ti] and
1004
+ orig_word[:_ti] == corr_word[:_ti] and
1005
+ orig_word[_ti + 2:] == corr_word[_ti + 2:]):
1006
+ _transposition_found = True
1007
+ break
1008
+ if _transposition_found:
1009
+ if vocab_manager:
1010
+ _orig_oov = not vocab_manager.is_iv(orig_word)
1011
+ _corr_iv = vocab_manager.is_iv(corr_word)
1012
+ if _orig_oov and _corr_iv:
1013
+ logger.info(
1014
+ f"[SPELLING] Transposition accepted (OOV→IV): "
1015
+ f"'{orig_word}'→'{corr_word}'"
1016
+ )
1017
+ return 0.6 # Dampened confidence for transpositions
1018
+ elif _orig_oov and not _corr_iv:
1019
+ # Both OOV — still accept transposition with lower confidence
1020
+ logger.info(
1021
+ f"[SPELLING] Transposition accepted (OOV→OOV): "
1022
+ f"'{orig_word}'→'{corr_word}' (low confidence)"
1023
+ )
1024
+ return 0.5
1025
+ else:
1026
+ return 0.6 # No vocab manager — accept with dampened confidence
1027
+
1028
+ # ── Phase 13: Single character insertion detection ──
1029
+ # When the original has one extra character (user typed an extra letter),
1030
+ # e.g., الكتتاب→الكتاب (extra ت). Levenshtein=1, lengths differ by 1.
1031
+ if len(orig_word) == len(corr_word) + 1 and dist == 1:
1032
+ # Find where the extra character is in orig_word
1033
+ _insertion_valid = False
1034
+ for _di in range(len(orig_word)):
1035
+ # Try removing character at position _di from orig_word
1036
+ _candidate = orig_word[:_di] + orig_word[_di + 1:]
1037
+ if _candidate == corr_word:
1038
+ _insertion_valid = True
1039
+ break
1040
+ if _insertion_valid:
1041
+ if vocab_manager:
1042
+ _orig_oov = not vocab_manager.is_iv(orig_word)
1043
+ _corr_iv = vocab_manager.is_iv(corr_word)
1044
+ if _orig_oov and _corr_iv:
1045
+ logger.info(
1046
+ f"[SPELLING] Insertion fix accepted (OOV→IV): "
1047
+ f"'{orig_word}'→'{corr_word}' (extra char removed)"
1048
+ )
1049
+ return 0.7
1050
+ else:
1051
+ return 0.6
1052
+
1053
+ # ── Phase 13: Single character deletion detection ──
1054
+ # When the original is missing one character (user missed a key),
1055
+ # e.g., الكتب→الكتاب (missing ا). Levenshtein=1, lengths differ by 1.
1056
+ if len(corr_word) == len(orig_word) + 1 and dist == 1:
1057
+ # Find where the missing character should be in corr_word
1058
+ _deletion_valid = False
1059
+ for _di in range(len(corr_word)):
1060
+ # Try removing character at position _di from corr_word
1061
+ _candidate = corr_word[:_di] + corr_word[_di + 1:]
1062
+ if _candidate == orig_word:
1063
+ _deletion_valid = True
1064
+ break
1065
+ if _deletion_valid:
1066
+ if vocab_manager:
1067
+ _orig_oov = not vocab_manager.is_iv(orig_word)
1068
+ _corr_iv = vocab_manager.is_iv(corr_word)
1069
+ if _orig_oov and _corr_iv:
1070
+ logger.info(
1071
+ f"[SPELLING] Deletion fix accepted (OOV→IV): "
1072
+ f"'{orig_word}'→'{corr_word}' (missing char added)"
1073
+ )
1074
+ return 0.7
1075
+ else:
1076
+ return 0.6
1077
+
1078
  # Check every character pair — reject if ANY non-orthographic change
1079
  if len(orig_word) != len(corr_word):
1080
  # Length change = structural change, not just orthographic
 
1083
  return 0.0
1084
  # ── Phase 12 (A1): Keyboard-neighbor and phonetic acceptance ──
1085
  # Check each differing character: ortho → full accept, keyboard/phonetic → dampened
 
1086
  _has_keyboard_or_phonetic = False
1087
  for a, b in zip(orig_word, corr_word):
1088
  if a != b:
 
1711
  f"[SPELLING] Bidirectional fix: "
1712
  f"'{_safe_words[_bi]}'(OOV)→'{_raw_words[_bi]}'(IV)"
1713
  )
1714
+ # ── Phase 13: Create patch for bidirectional fix ──
1715
+ # Find this word's position in the ORIGINAL text so the
1716
+ # user sees the correction as a suggestion in the UI.
1717
+ try:
1718
+ _orig_words_list = text.split()
1719
+ if _bi < len(_orig_words_list):
1720
+ _bidi_orig_word = _orig_words_list[_bi]
1721
+ # Only create patch if the original word matches
1722
+ # (bidirectional fix is correcting a filter-rejected word)
1723
+ if _bidi_orig_word == _safe_words[_bi]:
1724
+ _bidi_pos = 0
1725
+ for _bw_idx in range(_bi):
1726
+ _next_pos = text.find(_orig_words_list[_bw_idx], _bidi_pos)
1727
+ if _next_pos >= 0:
1728
+ _bidi_pos = _next_pos + len(_orig_words_list[_bw_idx])
1729
+ _bidi_start = text.find(_bidi_orig_word, max(0, _bidi_pos))
1730
+ if _bidi_start >= 0:
1731
+ _bidi_end = _bidi_start + len(_bidi_orig_word)
1732
+ ctx.add_patch(
1733
+ 'spelling', _bidi_start, _bidi_end,
1734
+ _raw_words[_bi], confidence=0.6,
1735
+ alternatives=[_raw_words[_bi], _bidi_orig_word],
1736
+ )
1737
+ except Exception:
1738
+ pass # Patch creation is best-effort
1739
  _safe_words[_bi] = _raw_words[_bi]
1740
  _bidi_changed = True
1741
  if _bidi_changed:
 
2310
  _models_loaded = False
2311
 
2312
  def _ensure_models_loaded():
2313
+ """Load ALL models at startup — no lazy loading.
2314
+
2315
+ Each model is wrapped in its own try/except so a single failure
2316
+ doesn't prevent the server from starting. Models that fail to load
2317
+ will gracefully degrade at request time.
2318
+ """
2319
  global _models_loaded
2320
  if _models_loaded:
2321
  return
2322
  _models_loaded = True
2323
+
2324
+ total_t0 = time.time()
2325
+ logger.info("=" * 60)
2326
+ logger.info("BAYAN — Loading ALL models at startup (eager mode)...")
2327
+ logger.info("=" * 60)
2328
+
2329
+ # 1. Summarization (legacy load_models)
2330
  if not load_models():
2331
+ logger.error("Failed to load summarization model.")
2332
+
2333
+ # 2. Spelling model
2334
+ try:
2335
+ t0 = time.time()
2336
+ from nlp.spelling.araspell_service import get_spelling_model
2337
+ get_spelling_model()
2338
+ logger.info(f"✓ Spelling model loaded in {time.time()-t0:.1f}s")
2339
+ except Exception as e:
2340
+ logger.error(f"✗ Spelling model failed to load: {e}")
2341
+
2342
+ # 3. Grammar model (Gradio client + camel-tools rules)
2343
+ try:
2344
+ t0 = time.time()
2345
+ from nlp.grammar.grammar_service import get_grammar_model
2346
+ get_grammar_model()
2347
+ logger.info(f"✓ Grammar model loaded in {time.time()-t0:.1f}s")
2348
+ except Exception as e:
2349
+ logger.error(f"✗ Grammar model failed to load: {e}")
2350
+
2351
+ # 4. Punctuation model
2352
+ try:
2353
+ t0 = time.time()
2354
+ from nlp.punctuation.punctuation_service import get_punctuation_model
2355
+ get_punctuation_model()
2356
+ logger.info(f"✓ Punctuation model loaded in {time.time()-t0:.1f}s")
2357
+ except Exception as e:
2358
+ logger.error(f"✗ Punctuation model failed to load: {e}")
2359
+
2360
+ # 5. Autocomplete model
2361
+ try:
2362
+ t0 = time.time()
2363
+ from nlp.autocomplete.autocomplete_service import get_autocomplete_model
2364
+ get_autocomplete_model()
2365
+ logger.info(f"✓ Autocomplete model loaded in {time.time()-t0:.1f}s")
2366
+ except Exception as e:
2367
+ logger.error(f"✗ Autocomplete model failed to load: {e}")
2368
+
2369
+ # 6. Dialect model
2370
+ try:
2371
+ t0 = time.time()
2372
+ from nlp.dialect.dialect_service import get_dialect_model
2373
+ get_dialect_model()
2374
+ logger.info(f"✓ Dialect model loaded in {time.time()-t0:.1f}s")
2375
+ except Exception as e:
2376
+ logger.error(f"✗ Dialect model failed to load: {e}")
2377
+
2378
+ total_elapsed = time.time() - total_t0
2379
+ logger.info("=" * 60)
2380
+ logger.info(f"BAYAN — All models loaded in {total_elapsed:.1f}s")
2381
+ logger.info("=" * 60)
2382
 
2383
  # Load models on import (gunicorn imports this module, __name__ != '__main__')
2384
  _ensure_models_loaded()
2385
 
2386
 
2387
  if __name__ == '__main__':
2388
+ # Models already loaded above via _ensure_models_loaded()
2389
+
 
2390
  # Run the app
2391
  port = int(os.environ.get('PORT', 5000))
2392
  debug = os.environ.get('DEBUG', 'False').lower() == 'true'