Phase 13: Fix spelling filter blind spots + eager model loading
Browse filesSpelling 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.
- src/app.py +176 -6
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2209 |
if not load_models():
|
| 2210 |
-
logger.error("Failed to load
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2211 |
|
| 2212 |
# Load models on import (gunicorn imports this module, __name__ != '__main__')
|
| 2213 |
_ensure_models_loaded()
|
| 2214 |
|
| 2215 |
|
| 2216 |
if __name__ == '__main__':
|
| 2217 |
-
#
|
| 2218 |
-
|
| 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'
|