feat(NLP-4): AutoComplete integration — hybrid bigram + GPT-2
Browse filesCOMPLETELY INDEPENDENT from the correction pipeline. Zero interaction
with /api/analyze, StageLocker, OffsetMapper, PatchSet, or highlights.
Backend:
- src/nlp/autocomplete/ package (service, rules, init)
- HybridAutoComplete: lazy-loaded singleton, LRU cache, OOM fallback
- Bigram model from bayan10/AutoComplete (bigram_model_v4.pkl)
- GPT-2 from aubmindlab/aragpt2-base (optional, falls back to bigram)
- POST /api/autocomplete with {context, n} → {status, suggestions}
- /api/health now reports autocomplete status
Frontend:
- src/js/autocomplete.js — self-contained module
- Ghost text overlay (gray, semi-transparent, pointer-events:none)
- Dropdown with keyboard nav (↑/↓), TAB accept, ESC dismiss
- 400ms debounce, min 3 chars, cursor-aware context extraction
- CSS in components.css with dark/light theme support
59/59 existing pipeline tests passing
- src/app.py +47 -29
- src/css/components.css +116 -0
- src/index.html +1 -0
- src/js/autocomplete.js +425 -0
- src/nlp/autocomplete/__init__.py +1 -0
- src/nlp/autocomplete/autocomplete_rules.py +86 -0
- src/nlp/autocomplete/autocomplete_service.py +307 -0
|
@@ -158,7 +158,7 @@ def health_check():
|
|
| 158 |
'models': {
|
| 159 |
'summarization': summarization_model is not None,
|
| 160 |
'spelling': _spelling_available(),
|
| 161 |
-
'autocomplete':
|
| 162 |
'grammar': _grammar_available(),
|
| 163 |
'punctuation': _punctuation_available()
|
| 164 |
},
|
|
@@ -254,6 +254,15 @@ def _punctuation_available():
|
|
| 254 |
return False
|
| 255 |
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
@app.route('/api/spelling', methods=['POST'])
|
| 258 |
def spelling_correction():
|
| 259 |
"""
|
|
@@ -409,50 +418,59 @@ def summarize():
|
|
| 409 |
def autocomplete():
|
| 410 |
"""
|
| 411 |
Get autocomplete suggestions for Arabic text.
|
| 412 |
-
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
{
|
| 415 |
-
"
|
| 416 |
-
"
|
| 417 |
}
|
| 418 |
"""
|
| 419 |
-
if not USE_HF_API and autocomplete_model is None:
|
| 420 |
-
return jsonify({
|
| 421 |
-
'error': 'Autocomplete model not loaded. Please check server logs.',
|
| 422 |
-
'status': 'error'
|
| 423 |
-
}), 503
|
| 424 |
-
|
| 425 |
try:
|
| 426 |
if not request.is_json:
|
| 427 |
return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400
|
| 428 |
-
|
| 429 |
data = request.get_json()
|
| 430 |
-
|
| 431 |
n = int(data.get('n', 5))
|
| 432 |
-
|
| 433 |
-
if not
|
| 434 |
-
return jsonify({'
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
return jsonify({
|
| 444 |
'suggestions': suggestions,
|
| 445 |
'status': 'success'
|
| 446 |
})
|
| 447 |
-
|
| 448 |
except Exception as e:
|
| 449 |
logger.error(f"Error during autocomplete: {str(e)}")
|
| 450 |
logger.error(traceback.format_exc())
|
| 451 |
return jsonify({
|
| 452 |
-
'
|
| 453 |
-
'status': '
|
| 454 |
-
|
| 455 |
-
}), 500
|
| 456 |
|
| 457 |
|
| 458 |
@app.route('/api/grammar', methods=['POST'])
|
|
|
|
| 158 |
'models': {
|
| 159 |
'summarization': summarization_model is not None,
|
| 160 |
'spelling': _spelling_available(),
|
| 161 |
+
'autocomplete': _autocomplete_available(),
|
| 162 |
'grammar': _grammar_available(),
|
| 163 |
'punctuation': _punctuation_available()
|
| 164 |
},
|
|
|
|
| 254 |
return False
|
| 255 |
|
| 256 |
|
| 257 |
+
def _autocomplete_available():
|
| 258 |
+
"""Check if autocomplete model is loaded (without triggering lazy load)."""
|
| 259 |
+
try:
|
| 260 |
+
from nlp.autocomplete.autocomplete_service import _instance
|
| 261 |
+
return _instance is not None and _instance.is_ready()
|
| 262 |
+
except Exception:
|
| 263 |
+
return False
|
| 264 |
+
|
| 265 |
+
|
| 266 |
@app.route('/api/spelling', methods=['POST'])
|
| 267 |
def spelling_correction():
|
| 268 |
"""
|
|
|
|
| 418 |
def autocomplete():
|
| 419 |
"""
|
| 420 |
Get autocomplete suggestions for Arabic text.
|
| 421 |
+
COMPLETELY INDEPENDENT — has zero interaction with /api/analyze.
|
| 422 |
+
|
| 423 |
+
Request JSON:
|
| 424 |
+
{
|
| 425 |
+
"context": "<text before cursor>",
|
| 426 |
+
"n": 5 (optional)
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
Response JSON:
|
| 430 |
{
|
| 431 |
+
"status": "success",
|
| 432 |
+
"suggestions": ["word1", "word2", ...]
|
| 433 |
}
|
| 434 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
try:
|
| 436 |
if not request.is_json:
|
| 437 |
return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400
|
| 438 |
+
|
| 439 |
data = request.get_json()
|
| 440 |
+
context = data.get('context', '').strip()
|
| 441 |
n = int(data.get('n', 5))
|
| 442 |
+
|
| 443 |
+
if not context or len(context) < 3:
|
| 444 |
+
return jsonify({'suggestions': [], 'status': 'success'})
|
| 445 |
+
|
| 446 |
+
# Extract last ~200 chars (trimmed to word boundary)
|
| 447 |
+
from nlp.autocomplete.autocomplete_rules import extract_context
|
| 448 |
+
context = extract_context(context, max_chars=200)
|
| 449 |
+
|
| 450 |
+
# Lazy-load the model on first request
|
| 451 |
+
from nlp.autocomplete.autocomplete_service import get_autocomplete_model
|
| 452 |
+
ac_model = get_autocomplete_model()
|
| 453 |
+
|
| 454 |
+
if not ac_model.is_ready():
|
| 455 |
+
return jsonify({'suggestions': [], 'status': 'success'})
|
| 456 |
+
|
| 457 |
+
t0 = time.time()
|
| 458 |
+
suggestions = ac_model.predict(context, n=n)
|
| 459 |
+
elapsed = int((time.time() - t0) * 1000)
|
| 460 |
+
logger.info(f"[AUTOCOMPLETE] {elapsed}ms | mode={ac_model.get_mode()} | suggestions={suggestions}")
|
| 461 |
+
|
| 462 |
return jsonify({
|
| 463 |
'suggestions': suggestions,
|
| 464 |
'status': 'success'
|
| 465 |
})
|
| 466 |
+
|
| 467 |
except Exception as e:
|
| 468 |
logger.error(f"Error during autocomplete: {str(e)}")
|
| 469 |
logger.error(traceback.format_exc())
|
| 470 |
return jsonify({
|
| 471 |
+
'suggestions': [],
|
| 472 |
+
'status': 'success' # Graceful degradation — never fail the UI
|
| 473 |
+
})
|
|
|
|
| 474 |
|
| 475 |
|
| 476 |
@app.route('/api/grammar', methods=['POST'])
|
|
@@ -2794,3 +2794,119 @@ input[type="range"]::-moz-range-thumb {
|
|
| 2794 |
box-shadow: 0 4px 16px rgba(26, 29, 33, 0.15);
|
| 2795 |
}
|
| 2796 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2794 |
box-shadow: 0 4px 16px rgba(26, 29, 33, 0.15);
|
| 2795 |
}
|
| 2796 |
|
| 2797 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 2798 |
+
NLP-4: AutoComplete — Ghost Text + Dropdown
|
| 2799 |
+
COMPLETELY INDEPENDENT from correction pipeline highlights.
|
| 2800 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 2801 |
+
|
| 2802 |
+
/* ── Ghost Text Overlay ── */
|
| 2803 |
+
#autocomplete-ghost {
|
| 2804 |
+
position: absolute;
|
| 2805 |
+
pointer-events: none;
|
| 2806 |
+
color: var(--color-text-muted, #6b7280);
|
| 2807 |
+
opacity: 0.45;
|
| 2808 |
+
font-family: inherit;
|
| 2809 |
+
font-size: inherit;
|
| 2810 |
+
line-height: inherit;
|
| 2811 |
+
z-index: 5;
|
| 2812 |
+
white-space: pre;
|
| 2813 |
+
direction: rtl;
|
| 2814 |
+
display: none;
|
| 2815 |
+
user-select: none;
|
| 2816 |
+
-webkit-user-select: none;
|
| 2817 |
+
}
|
| 2818 |
+
|
| 2819 |
+
/* ── Dropdown ── */
|
| 2820 |
+
#autocomplete-dropdown {
|
| 2821 |
+
position: fixed;
|
| 2822 |
+
background: var(--color-surface, #1a1d21);
|
| 2823 |
+
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
| 2824 |
+
border-radius: 10px;
|
| 2825 |
+
box-shadow:
|
| 2826 |
+
0 4px 24px rgba(0, 0, 0, 0.25),
|
| 2827 |
+
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
| 2828 |
+
z-index: 9999;
|
| 2829 |
+
max-height: 220px;
|
| 2830 |
+
overflow-y: auto;
|
| 2831 |
+
direction: rtl;
|
| 2832 |
+
min-width: 160px;
|
| 2833 |
+
padding: 4px;
|
| 2834 |
+
backdrop-filter: blur(12px);
|
| 2835 |
+
-webkit-backdrop-filter: blur(12px);
|
| 2836 |
+
animation: ac-dropdown-in 0.15s ease;
|
| 2837 |
+
}
|
| 2838 |
+
|
| 2839 |
+
@keyframes ac-dropdown-in {
|
| 2840 |
+
from { opacity: 0; transform: translateY(-4px); }
|
| 2841 |
+
to { opacity: 1; transform: translateY(0); }
|
| 2842 |
+
}
|
| 2843 |
+
|
| 2844 |
+
/* ── Dropdown Items ── */
|
| 2845 |
+
.ac-dropdown-item {
|
| 2846 |
+
padding: 8px 14px;
|
| 2847 |
+
font-size: 0.95rem;
|
| 2848 |
+
color: var(--color-text-primary, #e5e7eb);
|
| 2849 |
+
cursor: pointer;
|
| 2850 |
+
border-radius: 7px;
|
| 2851 |
+
transition: background 0.12s ease, color 0.12s ease;
|
| 2852 |
+
direction: rtl;
|
| 2853 |
+
text-align: right;
|
| 2854 |
+
font-family: inherit;
|
| 2855 |
+
line-height: 1.5;
|
| 2856 |
+
}
|
| 2857 |
+
|
| 2858 |
+
.ac-dropdown-item:hover,
|
| 2859 |
+
.ac-dropdown-item.ac-selected {
|
| 2860 |
+
background: linear-gradient(135deg,
|
| 2861 |
+
rgba(107, 163, 224, 0.15),
|
| 2862 |
+
rgba(160, 131, 237, 0.12));
|
| 2863 |
+
color: var(--color-primary, #6ba3e0);
|
| 2864 |
+
}
|
| 2865 |
+
|
| 2866 |
+
.ac-dropdown-item.ac-selected {
|
| 2867 |
+
font-weight: 600;
|
| 2868 |
+
}
|
| 2869 |
+
|
| 2870 |
+
/* ── Scrollbar ── */
|
| 2871 |
+
#autocomplete-dropdown::-webkit-scrollbar {
|
| 2872 |
+
width: 4px;
|
| 2873 |
+
}
|
| 2874 |
+
#autocomplete-dropdown::-webkit-scrollbar-track {
|
| 2875 |
+
background: transparent;
|
| 2876 |
+
}
|
| 2877 |
+
#autocomplete-dropdown::-webkit-scrollbar-thumb {
|
| 2878 |
+
background: rgba(255, 255, 255, 0.12);
|
| 2879 |
+
border-radius: 2px;
|
| 2880 |
+
}
|
| 2881 |
+
|
| 2882 |
+
/* ── Light theme overrides ── */
|
| 2883 |
+
[data-theme="light"] #autocomplete-ghost {
|
| 2884 |
+
color: #9ca3af;
|
| 2885 |
+
opacity: 0.5;
|
| 2886 |
+
}
|
| 2887 |
+
|
| 2888 |
+
[data-theme="light"] #autocomplete-dropdown {
|
| 2889 |
+
background: #ffffff;
|
| 2890 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 2891 |
+
box-shadow:
|
| 2892 |
+
0 4px 24px rgba(0, 0, 0, 0.12),
|
| 2893 |
+
0 0 0 1px rgba(0, 0, 0, 0.04) inset;
|
| 2894 |
+
}
|
| 2895 |
+
|
| 2896 |
+
[data-theme="light"] .ac-dropdown-item {
|
| 2897 |
+
color: #374151;
|
| 2898 |
+
}
|
| 2899 |
+
|
| 2900 |
+
[data-theme="light"] .ac-dropdown-item:hover,
|
| 2901 |
+
[data-theme="light"] .ac-dropdown-item.ac-selected {
|
| 2902 |
+
background: rgba(107, 163, 224, 0.1);
|
| 2903 |
+
color: #2563eb;
|
| 2904 |
+
}
|
| 2905 |
+
|
| 2906 |
+
/* ── Print: hide autocomplete ── */
|
| 2907 |
+
@media print {
|
| 2908 |
+
#autocomplete-ghost,
|
| 2909 |
+
#autocomplete-dropdown {
|
| 2910 |
+
display: none !important;
|
| 2911 |
+
}
|
| 2912 |
+
}
|
|
@@ -32,6 +32,7 @@
|
|
| 32 |
<script src="/js/ui.js"></script>
|
| 33 |
<script src="/js/documents/doc-utils.js"></script>
|
| 34 |
<script src="/js/editor.js"></script>
|
|
|
|
| 35 |
<script src="/js/format.js"></script>
|
| 36 |
<script src="/js/documents/import.js"></script>
|
| 37 |
<script src="/js/documents/export.js?v=20260615d"></script>
|
|
|
|
| 32 |
<script src="/js/ui.js"></script>
|
| 33 |
<script src="/js/documents/doc-utils.js"></script>
|
| 34 |
<script src="/js/editor.js"></script>
|
| 35 |
+
<script src="/js/autocomplete.js"></script>
|
| 36 |
<script src="/js/format.js"></script>
|
| 37 |
<script src="/js/documents/import.js"></script>
|
| 38 |
<script src="/js/documents/export.js?v=20260615d"></script>
|
|
@@ -0,0 +1,425 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AutoComplete Module — Ghost Text + Dropdown for Arabic autocomplete.
|
| 3 |
+
*
|
| 4 |
+
* COMPLETELY INDEPENDENT from the correction pipeline.
|
| 5 |
+
* This module has ZERO interaction with:
|
| 6 |
+
* - editor.js correction/highlight logic
|
| 7 |
+
* - renderer.js span rendering
|
| 8 |
+
* - ui.js suggestion sidebar
|
| 9 |
+
* - /api/analyze
|
| 10 |
+
*
|
| 11 |
+
* It only talks to: /api/autocomplete (its own endpoint)
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
(function () {
|
| 15 |
+
'use strict';
|
| 16 |
+
|
| 17 |
+
// ─── Configuration ───────────────────────────────────────────────
|
| 18 |
+
const DEBOUNCE_MS = 400;
|
| 19 |
+
const MIN_CONTEXT_LEN = 3;
|
| 20 |
+
const MAX_SUGGESTIONS = 5;
|
| 21 |
+
const CONTEXT_CHARS = 200;
|
| 22 |
+
|
| 23 |
+
// ─── State ───────────────────────────────────────────────────────
|
| 24 |
+
let ghostEl = null;
|
| 25 |
+
let dropdownEl = null;
|
| 26 |
+
let selectedIndex = -1;
|
| 27 |
+
let currentSuggestions = [];
|
| 28 |
+
let debounceTimer = null;
|
| 29 |
+
let isComposing = false;
|
| 30 |
+
let editorEl = null;
|
| 31 |
+
|
| 32 |
+
// ─── Initialization ──────────────────────────────────────────────
|
| 33 |
+
function init() {
|
| 34 |
+
editorEl = document.getElementById('editor-container');
|
| 35 |
+
if (!editorEl) {
|
| 36 |
+
// Retry after DOM is ready
|
| 37 |
+
setTimeout(init, 500);
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
createGhostElement();
|
| 42 |
+
createDropdownElement();
|
| 43 |
+
bindEvents();
|
| 44 |
+
console.log('[AutoComplete] Initialized');
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// ─── Ghost Text Element ──────────────────────────────────────────
|
| 48 |
+
function createGhostElement() {
|
| 49 |
+
ghostEl = document.createElement('div');
|
| 50 |
+
ghostEl.id = 'autocomplete-ghost';
|
| 51 |
+
ghostEl.setAttribute('aria-hidden', 'true');
|
| 52 |
+
// Position relative to editor's parent
|
| 53 |
+
const editorParent = editorEl.parentElement;
|
| 54 |
+
if (editorParent) {
|
| 55 |
+
editorParent.style.position = 'relative';
|
| 56 |
+
editorParent.appendChild(ghostEl);
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// ─── Dropdown Element ────────────────────────────────────────────
|
| 61 |
+
function createDropdownElement() {
|
| 62 |
+
dropdownEl = document.createElement('div');
|
| 63 |
+
dropdownEl.id = 'autocomplete-dropdown';
|
| 64 |
+
dropdownEl.setAttribute('role', 'listbox');
|
| 65 |
+
dropdownEl.setAttribute('aria-label', 'اقتراحات الإكمال التلقائي');
|
| 66 |
+
dropdownEl.style.display = 'none';
|
| 67 |
+
document.body.appendChild(dropdownEl);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// ─── Event Binding ───────────────────────────────────────────────
|
| 71 |
+
function bindEvents() {
|
| 72 |
+
// Typing → debounced autocomplete
|
| 73 |
+
editorEl.addEventListener('input', onInput);
|
| 74 |
+
|
| 75 |
+
// Composition events (IME)
|
| 76 |
+
editorEl.addEventListener('compositionstart', () => { isComposing = true; });
|
| 77 |
+
editorEl.addEventListener('compositionend', () => { isComposing = false; });
|
| 78 |
+
|
| 79 |
+
// Keyboard: TAB accept, ESC dismiss, arrow navigation
|
| 80 |
+
editorEl.addEventListener('keydown', onKeyDown);
|
| 81 |
+
|
| 82 |
+
// Cursor movement / selection change → dismiss
|
| 83 |
+
document.addEventListener('selectionchange', onSelectionChange);
|
| 84 |
+
|
| 85 |
+
// Click outside → dismiss
|
| 86 |
+
document.addEventListener('mousedown', function (e) {
|
| 87 |
+
if (dropdownEl && !dropdownEl.contains(e.target) && e.target !== editorEl) {
|
| 88 |
+
dismiss();
|
| 89 |
+
}
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
// Scroll → reposition
|
| 93 |
+
editorEl.addEventListener('scroll', dismiss);
|
| 94 |
+
|
| 95 |
+
// Window resize → dismiss
|
| 96 |
+
window.addEventListener('resize', dismiss);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// ─── Input Handler ───────────────────────────────────────────────
|
| 100 |
+
function onInput() {
|
| 101 |
+
if (isComposing) return;
|
| 102 |
+
|
| 103 |
+
clearTimeout(debounceTimer);
|
| 104 |
+
debounceTimer = setTimeout(fetchSuggestions, DEBOUNCE_MS);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// ─── Selection Change → Dismiss ──────────────────────────────────
|
| 108 |
+
function onSelectionChange() {
|
| 109 |
+
const sel = window.getSelection();
|
| 110 |
+
if (!sel || !sel.isCollapsed) {
|
| 111 |
+
dismiss();
|
| 112 |
+
return;
|
| 113 |
+
}
|
| 114 |
+
// If cursor moved (not from typing), dismiss
|
| 115 |
+
// We rely on the debounce to re-trigger if user is still typing
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// ─── Keyboard Handler ───────────────────────────────────────────
|
| 119 |
+
function onKeyDown(e) {
|
| 120 |
+
if (!isVisible()) return;
|
| 121 |
+
|
| 122 |
+
switch (e.key) {
|
| 123 |
+
case 'Tab':
|
| 124 |
+
e.preventDefault();
|
| 125 |
+
acceptSuggestion();
|
| 126 |
+
break;
|
| 127 |
+
|
| 128 |
+
case 'Escape':
|
| 129 |
+
e.preventDefault();
|
| 130 |
+
dismiss();
|
| 131 |
+
break;
|
| 132 |
+
|
| 133 |
+
case 'ArrowDown':
|
| 134 |
+
e.preventDefault();
|
| 135 |
+
navigateDropdown(1);
|
| 136 |
+
break;
|
| 137 |
+
|
| 138 |
+
case 'ArrowUp':
|
| 139 |
+
e.preventDefault();
|
| 140 |
+
navigateDropdown(-1);
|
| 141 |
+
break;
|
| 142 |
+
|
| 143 |
+
default:
|
| 144 |
+
// Any other key → will trigger onInput → new debounce
|
| 145 |
+
// Dismiss current ghost immediately for responsiveness
|
| 146 |
+
hideGhost();
|
| 147 |
+
break;
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// ─── Fetch Suggestions ───────────────────────────────────────────
|
| 152 |
+
async function fetchSuggestions() {
|
| 153 |
+
const sel = window.getSelection();
|
| 154 |
+
if (!sel || !sel.isCollapsed || !sel.rangeCount) {
|
| 155 |
+
dismiss();
|
| 156 |
+
return;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// Check the editor has text
|
| 160 |
+
const text = editorEl.innerText || editorEl.textContent || '';
|
| 161 |
+
if (text.trim().length < MIN_CONTEXT_LEN) {
|
| 162 |
+
dismiss();
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// Extract context: text before cursor (last N chars)
|
| 167 |
+
const context = getTextBeforeCursor(CONTEXT_CHARS);
|
| 168 |
+
if (!context || context.trim().length < MIN_CONTEXT_LEN) {
|
| 169 |
+
dismiss();
|
| 170 |
+
return;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
try {
|
| 174 |
+
const resp = await fetch('/api/autocomplete', {
|
| 175 |
+
method: 'POST',
|
| 176 |
+
headers: { 'Content-Type': 'application/json' },
|
| 177 |
+
body: JSON.stringify({
|
| 178 |
+
context: context,
|
| 179 |
+
n: MAX_SUGGESTIONS
|
| 180 |
+
})
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
if (!resp.ok) {
|
| 184 |
+
dismiss();
|
| 185 |
+
return;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
const data = await resp.json();
|
| 189 |
+
if (data.status !== 'success' || !data.suggestions || !data.suggestions.length) {
|
| 190 |
+
dismiss();
|
| 191 |
+
return;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
showSuggestions(data.suggestions);
|
| 195 |
+
|
| 196 |
+
} catch (err) {
|
| 197 |
+
console.warn('[AutoComplete] Fetch error:', err);
|
| 198 |
+
dismiss();
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// ─── Get Text Before Cursor ──────────────────────────────────────
|
| 203 |
+
function getTextBeforeCursor(maxChars) {
|
| 204 |
+
const sel = window.getSelection();
|
| 205 |
+
if (!sel || !sel.rangeCount) return '';
|
| 206 |
+
|
| 207 |
+
try {
|
| 208 |
+
const range = sel.getRangeAt(0);
|
| 209 |
+
const preRange = document.createRange();
|
| 210 |
+
preRange.selectNodeContents(editorEl);
|
| 211 |
+
preRange.setEnd(range.startContainer, range.startOffset);
|
| 212 |
+
const text = preRange.toString();
|
| 213 |
+
preRange.detach();
|
| 214 |
+
|
| 215 |
+
if (text.length <= maxChars) return text;
|
| 216 |
+
return text.slice(-maxChars);
|
| 217 |
+
} catch (e) {
|
| 218 |
+
return '';
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// ─── Show Suggestions ────────────────────────────────────────────
|
| 223 |
+
function showSuggestions(suggestions) {
|
| 224 |
+
currentSuggestions = suggestions;
|
| 225 |
+
selectedIndex = 0; // Pre-select first
|
| 226 |
+
|
| 227 |
+
// Show ghost text (best suggestion)
|
| 228 |
+
showGhost(suggestions[0]);
|
| 229 |
+
|
| 230 |
+
// Build dropdown
|
| 231 |
+
dropdownEl.innerHTML = '';
|
| 232 |
+
suggestions.forEach(function (word, idx) {
|
| 233 |
+
const item = document.createElement('div');
|
| 234 |
+
item.className = 'ac-dropdown-item' + (idx === 0 ? ' ac-selected' : '');
|
| 235 |
+
item.setAttribute('role', 'option');
|
| 236 |
+
item.textContent = word;
|
| 237 |
+
item.addEventListener('mousedown', function (e) {
|
| 238 |
+
e.preventDefault();
|
| 239 |
+
selectedIndex = idx;
|
| 240 |
+
acceptSuggestion();
|
| 241 |
+
});
|
| 242 |
+
item.addEventListener('mouseenter', function () {
|
| 243 |
+
selectedIndex = idx;
|
| 244 |
+
updateDropdownSelection();
|
| 245 |
+
});
|
| 246 |
+
dropdownEl.appendChild(item);
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
// Position dropdown near caret
|
| 250 |
+
positionDropdown();
|
| 251 |
+
dropdownEl.style.display = 'block';
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// ─── Ghost Text ──────────────────────────────────────────────────
|
| 255 |
+
function showGhost(text) {
|
| 256 |
+
if (!ghostEl || !text) return;
|
| 257 |
+
|
| 258 |
+
// Get caret position relative to editor
|
| 259 |
+
const caretPos = getCaretCoordinates();
|
| 260 |
+
if (!caretPos) {
|
| 261 |
+
hideGhost();
|
| 262 |
+
return;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
ghostEl.textContent = text;
|
| 266 |
+
ghostEl.style.display = 'block';
|
| 267 |
+
|
| 268 |
+
// Position ghost at caret
|
| 269 |
+
const editorRect = editorEl.getBoundingClientRect();
|
| 270 |
+
const parentRect = editorEl.parentElement.getBoundingClientRect();
|
| 271 |
+
|
| 272 |
+
// RTL: ghost appears to the LEFT of the caret
|
| 273 |
+
ghostEl.style.top = (caretPos.top - parentRect.top) + 'px';
|
| 274 |
+
ghostEl.style.right = (parentRect.right - caretPos.left + 4) + 'px';
|
| 275 |
+
ghostEl.style.left = 'auto';
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
function hideGhost() {
|
| 279 |
+
if (ghostEl) {
|
| 280 |
+
ghostEl.style.display = 'none';
|
| 281 |
+
ghostEl.textContent = '';
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// ─── Dropdown Position ───────────────────────────────────────────
|
| 286 |
+
function positionDropdown() {
|
| 287 |
+
const caretPos = getCaretCoordinates();
|
| 288 |
+
if (!caretPos) return;
|
| 289 |
+
|
| 290 |
+
const lineHeight = parseInt(getComputedStyle(editorEl).lineHeight) || 24;
|
| 291 |
+
|
| 292 |
+
// Position below caret
|
| 293 |
+
dropdownEl.style.position = 'fixed';
|
| 294 |
+
dropdownEl.style.top = (caretPos.bottom + 4) + 'px';
|
| 295 |
+
|
| 296 |
+
// RTL: align to the right of caret
|
| 297 |
+
dropdownEl.style.right = (window.innerWidth - caretPos.left) + 'px';
|
| 298 |
+
dropdownEl.style.left = 'auto';
|
| 299 |
+
|
| 300 |
+
// Ensure dropdown doesn't go off-screen
|
| 301 |
+
const rect = dropdownEl.getBoundingClientRect();
|
| 302 |
+
if (rect.bottom > window.innerHeight - 20) {
|
| 303 |
+
// Show above caret instead
|
| 304 |
+
dropdownEl.style.top = (caretPos.top - rect.height - 4) + 'px';
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// ─── Dropdown Navigation ─────────────────────────────────────────
|
| 309 |
+
function navigateDropdown(direction) {
|
| 310 |
+
if (!currentSuggestions.length) return;
|
| 311 |
+
|
| 312 |
+
selectedIndex += direction;
|
| 313 |
+
if (selectedIndex < 0) selectedIndex = currentSuggestions.length - 1;
|
| 314 |
+
if (selectedIndex >= currentSuggestions.length) selectedIndex = 0;
|
| 315 |
+
|
| 316 |
+
updateDropdownSelection();
|
| 317 |
+
showGhost(currentSuggestions[selectedIndex]);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
function updateDropdownSelection() {
|
| 321 |
+
const items = dropdownEl.querySelectorAll('.ac-dropdown-item');
|
| 322 |
+
items.forEach(function (item, idx) {
|
| 323 |
+
item.classList.toggle('ac-selected', idx === selectedIndex);
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
// Scroll selected item into view
|
| 327 |
+
const selected = dropdownEl.querySelector('.ac-selected');
|
| 328 |
+
if (selected) {
|
| 329 |
+
selected.scrollIntoView({ block: 'nearest' });
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// ─── Accept Suggestion ───────────────────────────────────────────
|
| 334 |
+
function acceptSuggestion() {
|
| 335 |
+
if (selectedIndex < 0 || selectedIndex >= currentSuggestions.length) {
|
| 336 |
+
dismiss();
|
| 337 |
+
return;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
const word = currentSuggestions[selectedIndex];
|
| 341 |
+
|
| 342 |
+
// Insert the word at cursor position
|
| 343 |
+
const sel = window.getSelection();
|
| 344 |
+
if (!sel || !sel.rangeCount) {
|
| 345 |
+
dismiss();
|
| 346 |
+
return;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
// Insert with a space before the word
|
| 350 |
+
const textToInsert = word + ' ';
|
| 351 |
+
const range = sel.getRangeAt(0);
|
| 352 |
+
range.deleteContents();
|
| 353 |
+
const textNode = document.createTextNode(textToInsert);
|
| 354 |
+
range.insertNode(textNode);
|
| 355 |
+
|
| 356 |
+
// Move caret to end of inserted text
|
| 357 |
+
range.setStartAfter(textNode);
|
| 358 |
+
range.setEndAfter(textNode);
|
| 359 |
+
sel.removeAllRanges();
|
| 360 |
+
sel.addRange(range);
|
| 361 |
+
|
| 362 |
+
dismiss();
|
| 363 |
+
|
| 364 |
+
// Trigger input event so the editor knows text changed
|
| 365 |
+
editorEl.dispatchEvent(new Event('input', { bubbles: true }));
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
// ─── Dismiss ─────────────────────────────────────────────────────
|
| 369 |
+
function dismiss() {
|
| 370 |
+
hideGhost();
|
| 371 |
+
currentSuggestions = [];
|
| 372 |
+
selectedIndex = -1;
|
| 373 |
+
if (dropdownEl) {
|
| 374 |
+
dropdownEl.style.display = 'none';
|
| 375 |
+
dropdownEl.innerHTML = '';
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
// ─── Helpers ─────────────────────────────────────────────────────
|
| 380 |
+
function isVisible() {
|
| 381 |
+
return dropdownEl && dropdownEl.style.display !== 'none';
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
function getCaretCoordinates() {
|
| 385 |
+
const sel = window.getSelection();
|
| 386 |
+
if (!sel || !sel.rangeCount) return null;
|
| 387 |
+
|
| 388 |
+
try {
|
| 389 |
+
const range = sel.getRangeAt(0).cloneRange();
|
| 390 |
+
range.collapse(true);
|
| 391 |
+
|
| 392 |
+
// Use a zero-width space to get coordinates
|
| 393 |
+
const span = document.createElement('span');
|
| 394 |
+
span.textContent = '\u200B';
|
| 395 |
+
range.insertNode(span);
|
| 396 |
+
|
| 397 |
+
const rect = span.getBoundingClientRect();
|
| 398 |
+
const coords = {
|
| 399 |
+
top: rect.top,
|
| 400 |
+
left: rect.left,
|
| 401 |
+
bottom: rect.bottom,
|
| 402 |
+
right: rect.right
|
| 403 |
+
};
|
| 404 |
+
|
| 405 |
+
// Clean up
|
| 406 |
+
span.parentNode.removeChild(span);
|
| 407 |
+
|
| 408 |
+
// Restore selection
|
| 409 |
+
sel.removeAllRanges();
|
| 410 |
+
sel.addRange(range);
|
| 411 |
+
|
| 412 |
+
return coords;
|
| 413 |
+
} catch (e) {
|
| 414 |
+
return null;
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// ─── Initialize on DOM ready ─────────────────────────────────────
|
| 419 |
+
if (document.readyState === 'loading') {
|
| 420 |
+
document.addEventListener('DOMContentLoaded', init);
|
| 421 |
+
} else {
|
| 422 |
+
init();
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
})();
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# AutoComplete package — completely independent from the correction pipeline
|
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AutoComplete Rules — text processing utilities for Arabic autocomplete.
|
| 3 |
+
Completely independent from the correction pipeline.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import re
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def canonical_form(word: str) -> str:
|
| 11 |
+
"""
|
| 12 |
+
Normalize Arabic word to a canonical form for deduplication.
|
| 13 |
+
Collapses hamza variants, ta marbuta, alef maqsura, and diacritics.
|
| 14 |
+
"""
|
| 15 |
+
word = re.sub("[إأآا]", "ا", word)
|
| 16 |
+
word = re.sub("ى", "ي", word)
|
| 17 |
+
word = re.sub("ؤ", "و", word)
|
| 18 |
+
word = re.sub("ئ", "ي", word)
|
| 19 |
+
word = re.sub("ة", "ه", word)
|
| 20 |
+
word = re.sub(r"[ًٌٍَُِّْ]", "", word)
|
| 21 |
+
return word
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def merge_similar_predictions(preds: list, top_k: int = 20) -> list:
|
| 25 |
+
"""
|
| 26 |
+
Merge predictions that differ only in diacritics/hamza variants.
|
| 27 |
+
Groups by canonical form, sums scores, keeps the first surface form.
|
| 28 |
+
"""
|
| 29 |
+
groups = defaultdict(lambda: {"score": 0.0, "words": []})
|
| 30 |
+
|
| 31 |
+
for w, p in preds:
|
| 32 |
+
key = canonical_form(w)
|
| 33 |
+
groups[key]["score"] += p
|
| 34 |
+
groups[key]["words"].append(w)
|
| 35 |
+
|
| 36 |
+
merged = sorted(
|
| 37 |
+
groups.values(),
|
| 38 |
+
key=lambda x: x["score"],
|
| 39 |
+
reverse=True
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
return [
|
| 43 |
+
(group["words"][0], group["score"])
|
| 44 |
+
for group in merged[:top_k]
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def filter_suggestions(suggestions: list, min_len: int = 2, max_len: int = 30) -> list:
|
| 49 |
+
"""
|
| 50 |
+
Filter autocomplete suggestions:
|
| 51 |
+
- Remove too-short or too-long words
|
| 52 |
+
- Remove non-Arabic words
|
| 53 |
+
- Remove punctuation-only tokens
|
| 54 |
+
"""
|
| 55 |
+
ARABIC_RE = re.compile(r'[\u0600-\u06FF]')
|
| 56 |
+
filtered = []
|
| 57 |
+
for word, score in suggestions:
|
| 58 |
+
word = word.strip()
|
| 59 |
+
if not word or len(word) < min_len or len(word) > max_len:
|
| 60 |
+
continue
|
| 61 |
+
if not ARABIC_RE.search(word):
|
| 62 |
+
continue
|
| 63 |
+
# Skip punctuation-only or whitespace-only
|
| 64 |
+
if all(c in '.,;:!?،؛؟!.:«»"\'()-–—… \t\n' for c in word):
|
| 65 |
+
continue
|
| 66 |
+
filtered.append((word, score))
|
| 67 |
+
return filtered
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def extract_context(text: str, max_chars: int = 200) -> str:
|
| 71 |
+
"""
|
| 72 |
+
Extract the last N characters of text, trimmed to a word boundary.
|
| 73 |
+
This is the context sent to the autocomplete model.
|
| 74 |
+
"""
|
| 75 |
+
if not text or len(text) <= max_chars:
|
| 76 |
+
return text.strip()
|
| 77 |
+
|
| 78 |
+
# Take last max_chars characters
|
| 79 |
+
snippet = text[-max_chars:]
|
| 80 |
+
|
| 81 |
+
# Trim to word boundary (don't send a partial word at the start)
|
| 82 |
+
first_space = snippet.find(' ')
|
| 83 |
+
if first_space > 0 and first_space < len(snippet) // 2:
|
| 84 |
+
snippet = snippet[first_space + 1:]
|
| 85 |
+
|
| 86 |
+
return snippet.strip()
|
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AutoComplete Service — Hybrid bigram + GPT-2 Arabic autocomplete.
|
| 3 |
+
|
| 4 |
+
COMPLETELY INDEPENDENT from the correction pipeline.
|
| 5 |
+
This module has ZERO interaction with:
|
| 6 |
+
- /api/analyze
|
| 7 |
+
- StageLockManager / OffsetMapper / ClaimedRanges
|
| 8 |
+
- OverlapResolver / PatchSet / CorrectionPatch
|
| 9 |
+
- Highlight rendering
|
| 10 |
+
|
| 11 |
+
Architecture:
|
| 12 |
+
User types → debounce → POST /api/autocomplete
|
| 13 |
+
→ HybridAutoComplete.predict(context)
|
| 14 |
+
→ Bigram lookup + GPT-2 scoring
|
| 15 |
+
→ Ranked suggestions returned to frontend
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import time
|
| 20 |
+
import pickle
|
| 21 |
+
import logging
|
| 22 |
+
import threading
|
| 23 |
+
from functools import lru_cache
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
from huggingface_hub import hf_hub_download
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
# ─── Singleton ────────────────────────────────────────────────────────────────
|
| 31 |
+
_instance = None
|
| 32 |
+
_lock = threading.Lock()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_autocomplete_model():
|
| 36 |
+
"""Lazy-loaded singleton — returns the cached HybridAutoComplete instance."""
|
| 37 |
+
global _instance
|
| 38 |
+
if _instance is not None:
|
| 39 |
+
return _instance
|
| 40 |
+
|
| 41 |
+
with _lock:
|
| 42 |
+
if _instance is not None:
|
| 43 |
+
return _instance
|
| 44 |
+
_instance = HybridAutoComplete()
|
| 45 |
+
return _instance
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ─── Cache key helper ─────────────────────────────────────────────────────────
|
| 49 |
+
def _context_key(context: str) -> str:
|
| 50 |
+
"""Reduce context to last 3 words for cache key."""
|
| 51 |
+
words = context.strip().split()
|
| 52 |
+
return " ".join(words[-3:]) if words else ""
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ─── Main Service ─────────────────────────────────────────────────────────────
|
| 56 |
+
class HybridAutoComplete:
|
| 57 |
+
"""
|
| 58 |
+
Hybrid Arabic autocomplete:
|
| 59 |
+
1. Statistical (bigram) — fast, always available
|
| 60 |
+
2. Neural (GPT-2) — contextual, optional (may OOM on free tier)
|
| 61 |
+
3. Hybrid scoring: alpha * stat + (1-alpha) * neural
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
BIGRAM_REPO = "bayan10/AutoComplete"
|
| 65 |
+
BIGRAM_FILE = "bigram_model_v4.pkl"
|
| 66 |
+
GPT2_MODEL = "aubmindlab/aragpt2-base"
|
| 67 |
+
|
| 68 |
+
def __init__(self):
|
| 69 |
+
t0 = time.time()
|
| 70 |
+
logger.info("Loading AutoComplete model (lazy init)...")
|
| 71 |
+
|
| 72 |
+
self.unigrams = None
|
| 73 |
+
self.bigrams = None
|
| 74 |
+
self.gpt2_tokenizer = None
|
| 75 |
+
self.gpt2_model = None
|
| 76 |
+
self.device = "cpu"
|
| 77 |
+
self.alpha = 0.6 # Weight: 60% bigram, 40% GPT-2
|
| 78 |
+
self._cache = {}
|
| 79 |
+
self._cache_max = 256
|
| 80 |
+
|
| 81 |
+
# 1. Load bigram (required — small file)
|
| 82 |
+
self._load_bigram()
|
| 83 |
+
|
| 84 |
+
# 2. Load GPT-2 (optional — large model, may OOM)
|
| 85 |
+
self._load_gpt2()
|
| 86 |
+
|
| 87 |
+
elapsed = time.time() - t0
|
| 88 |
+
mode = "hybrid" if self.gpt2_model else "bigram-only"
|
| 89 |
+
logger.info(f"AutoComplete ready in {elapsed:.1f}s (mode: {mode})")
|
| 90 |
+
|
| 91 |
+
def _load_bigram(self):
|
| 92 |
+
"""Load bigram model from HuggingFace Hub."""
|
| 93 |
+
try:
|
| 94 |
+
path = hf_hub_download(
|
| 95 |
+
repo_id=self.BIGRAM_REPO,
|
| 96 |
+
filename=self.BIGRAM_FILE,
|
| 97 |
+
)
|
| 98 |
+
with open(path, "rb") as f:
|
| 99 |
+
data = pickle.load(f)
|
| 100 |
+
self.unigrams = data["unigrams"]
|
| 101 |
+
self.bigrams = data["bigrams"]
|
| 102 |
+
logger.info(
|
| 103 |
+
f"Bigram model loaded: {len(self.unigrams)} unigrams, "
|
| 104 |
+
f"{len(self.bigrams)} bigram contexts"
|
| 105 |
+
)
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"Failed to load bigram model: {e}")
|
| 108 |
+
self.unigrams = {}
|
| 109 |
+
self.bigrams = {}
|
| 110 |
+
|
| 111 |
+
def _load_gpt2(self):
|
| 112 |
+
"""Load GPT-2 model with OOM fallback."""
|
| 113 |
+
try:
|
| 114 |
+
from transformers import GPT2LMHeadModel, GPT2Tokenizer
|
| 115 |
+
|
| 116 |
+
logger.info(f"Loading GPT-2 tokenizer: {self.GPT2_MODEL}")
|
| 117 |
+
self.gpt2_tokenizer = GPT2Tokenizer.from_pretrained(self.GPT2_MODEL)
|
| 118 |
+
self.gpt2_tokenizer.pad_token = self.gpt2_tokenizer.eos_token
|
| 119 |
+
|
| 120 |
+
logger.info(f"Loading GPT-2 model: {self.GPT2_MODEL}")
|
| 121 |
+
self.gpt2_model = GPT2LMHeadModel.from_pretrained(self.GPT2_MODEL)
|
| 122 |
+
self.gpt2_model.config.pad_token_id = self.gpt2_tokenizer.eos_token_id
|
| 123 |
+
self.gpt2_model.eval()
|
| 124 |
+
|
| 125 |
+
logger.info("GPT-2 loaded successfully (hybrid mode enabled)")
|
| 126 |
+
|
| 127 |
+
except (torch.cuda.OutOfMemoryError, MemoryError, RuntimeError) as e:
|
| 128 |
+
logger.warning(f"GPT-2 OOM — falling back to bigram-only mode: {e}")
|
| 129 |
+
self.gpt2_tokenizer = None
|
| 130 |
+
self.gpt2_model = None
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.warning(f"GPT-2 load failed — bigram-only mode: {e}")
|
| 133 |
+
self.gpt2_tokenizer = None
|
| 134 |
+
self.gpt2_model = None
|
| 135 |
+
|
| 136 |
+
# ─── Prediction ──────────────────────────────���────────────────────────
|
| 137 |
+
|
| 138 |
+
def predict(self, context: str, n: int = 5) -> list:
|
| 139 |
+
"""
|
| 140 |
+
Get top-N autocomplete suggestions for the given context.
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
context: Text before the cursor (last ~200 chars)
|
| 144 |
+
n: Number of suggestions to return
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
List of suggestion strings (ranked by score)
|
| 148 |
+
"""
|
| 149 |
+
if not context or not context.strip():
|
| 150 |
+
return []
|
| 151 |
+
|
| 152 |
+
context = context.strip()
|
| 153 |
+
|
| 154 |
+
# Check cache
|
| 155 |
+
cache_key = _context_key(context)
|
| 156 |
+
if cache_key in self._cache:
|
| 157 |
+
return self._cache[cache_key][:n]
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
if self.gpt2_model is not None:
|
| 161 |
+
results = self._hybrid_predict(context, n)
|
| 162 |
+
else:
|
| 163 |
+
results = self._bigram_predict(context, n)
|
| 164 |
+
|
| 165 |
+
# Cache the result
|
| 166 |
+
if len(self._cache) >= self._cache_max:
|
| 167 |
+
# Evict oldest entries (simple FIFO)
|
| 168 |
+
keys = list(self._cache.keys())
|
| 169 |
+
for k in keys[:len(keys) // 2]:
|
| 170 |
+
del self._cache[k]
|
| 171 |
+
self._cache[cache_key] = results
|
| 172 |
+
|
| 173 |
+
return results[:n]
|
| 174 |
+
|
| 175 |
+
except Exception as e:
|
| 176 |
+
logger.error(f"AutoComplete prediction error: {e}")
|
| 177 |
+
return []
|
| 178 |
+
|
| 179 |
+
def _bigram_predict(self, context: str, n: int = 5) -> list:
|
| 180 |
+
"""Statistical-only prediction using bigram model."""
|
| 181 |
+
from .autocomplete_rules import merge_similar_predictions, filter_suggestions
|
| 182 |
+
|
| 183 |
+
tokens = context.strip().split()
|
| 184 |
+
if not tokens:
|
| 185 |
+
return []
|
| 186 |
+
|
| 187 |
+
last_word = tokens[-1]
|
| 188 |
+
candidates = []
|
| 189 |
+
|
| 190 |
+
# Try bigram lookup
|
| 191 |
+
if last_word in self.bigrams:
|
| 192 |
+
for w, c in self.bigrams[last_word].items():
|
| 193 |
+
if len(w) < 2 or w == last_word:
|
| 194 |
+
continue
|
| 195 |
+
candidates.append((w, c))
|
| 196 |
+
|
| 197 |
+
# Fallback to unigram if no bigram matches
|
| 198 |
+
if not candidates:
|
| 199 |
+
for w, c in self.unigrams.items():
|
| 200 |
+
if len(w) < 2:
|
| 201 |
+
continue
|
| 202 |
+
candidates.append((w, c))
|
| 203 |
+
|
| 204 |
+
if not candidates:
|
| 205 |
+
return []
|
| 206 |
+
|
| 207 |
+
total = sum(c for _, c in candidates)
|
| 208 |
+
if total == 0:
|
| 209 |
+
return []
|
| 210 |
+
|
| 211 |
+
preds = [(w, c / total) for w, c in candidates]
|
| 212 |
+
preds.sort(key=lambda x: x[1], reverse=True)
|
| 213 |
+
preds = merge_similar_predictions(preds, top_k=n * 3)
|
| 214 |
+
preds = filter_suggestions(preds)
|
| 215 |
+
|
| 216 |
+
return [w for w, _ in preds[:n]]
|
| 217 |
+
|
| 218 |
+
def _hybrid_predict(self, context: str, n: int = 5) -> list:
|
| 219 |
+
"""Hybrid prediction: bigram + GPT-2 scoring."""
|
| 220 |
+
from .autocomplete_rules import merge_similar_predictions, filter_suggestions
|
| 221 |
+
|
| 222 |
+
tokens = context.strip().split()
|
| 223 |
+
if not tokens:
|
| 224 |
+
return []
|
| 225 |
+
|
| 226 |
+
last_word = tokens[-1]
|
| 227 |
+
|
| 228 |
+
# 1. Get bigram candidates
|
| 229 |
+
stat_candidates = []
|
| 230 |
+
if last_word in self.bigrams:
|
| 231 |
+
for w, c in self.bigrams[last_word].items():
|
| 232 |
+
if len(w) < 2 or w == last_word:
|
| 233 |
+
continue
|
| 234 |
+
stat_candidates.append((w, c))
|
| 235 |
+
|
| 236 |
+
if not stat_candidates:
|
| 237 |
+
# No bigram matches — fall back to bigram-only with unigrams
|
| 238 |
+
return self._bigram_predict(context, n)
|
| 239 |
+
|
| 240 |
+
total = sum(c for _, c in stat_candidates)
|
| 241 |
+
if total == 0:
|
| 242 |
+
return self._bigram_predict(context, n)
|
| 243 |
+
|
| 244 |
+
stat_preds = [(w, c / total) for w, c in stat_candidates]
|
| 245 |
+
stat_preds.sort(key=lambda x: x[1], reverse=True)
|
| 246 |
+
stat_preds = merge_similar_predictions(stat_preds, top_k=20)
|
| 247 |
+
|
| 248 |
+
# 2. Get GPT-2 next-token probabilities (ONCE)
|
| 249 |
+
gpt2_probs = self._gpt2_next_token_probs(context, top_k=50)
|
| 250 |
+
|
| 251 |
+
# 3. Hybrid scoring
|
| 252 |
+
results = []
|
| 253 |
+
for w, stat_p in stat_preds:
|
| 254 |
+
neural_p = gpt2_probs.get(w, 1e-8)
|
| 255 |
+
score = self.alpha * stat_p + (1 - self.alpha) * neural_p
|
| 256 |
+
results.append((w, score))
|
| 257 |
+
|
| 258 |
+
results.sort(key=lambda x: x[1], reverse=True)
|
| 259 |
+
results = filter_suggestions(results)
|
| 260 |
+
|
| 261 |
+
return [w for w, _ in results[:n]]
|
| 262 |
+
|
| 263 |
+
def _gpt2_next_token_probs(self, prefix: str, top_k: int = 50) -> dict:
|
| 264 |
+
"""Get GPT-2 next-token probability distribution."""
|
| 265 |
+
if self.gpt2_model is None or self.gpt2_tokenizer is None:
|
| 266 |
+
return {}
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
inputs = self.gpt2_tokenizer(
|
| 270 |
+
prefix,
|
| 271 |
+
return_tensors="pt",
|
| 272 |
+
truncation=True,
|
| 273 |
+
max_length=512, # Shorter than 1024 for speed
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
with torch.no_grad():
|
| 277 |
+
outputs = self.gpt2_model(**inputs)
|
| 278 |
+
logits = outputs.logits[0, -1]
|
| 279 |
+
|
| 280 |
+
probs = torch.softmax(logits, dim=-1)
|
| 281 |
+
top_probs, top_ids = torch.topk(probs, top_k)
|
| 282 |
+
|
| 283 |
+
prob_dict = {}
|
| 284 |
+
for idx, prob in zip(top_ids, top_probs):
|
| 285 |
+
word = self.gpt2_tokenizer.decode([idx]).strip()
|
| 286 |
+
if word and len(word) >= 2:
|
| 287 |
+
prob_dict[word] = prob.item()
|
| 288 |
+
|
| 289 |
+
return prob_dict
|
| 290 |
+
|
| 291 |
+
except Exception as e:
|
| 292 |
+
logger.warning(f"GPT-2 scoring failed: {e}")
|
| 293 |
+
return {}
|
| 294 |
+
|
| 295 |
+
# ─── Health ───────────────────────────────────────────────────────────
|
| 296 |
+
|
| 297 |
+
def is_ready(self) -> bool:
|
| 298 |
+
"""Returns True if at least the bigram model is loaded."""
|
| 299 |
+
return bool(self.unigrams)
|
| 300 |
+
|
| 301 |
+
def get_mode(self) -> str:
|
| 302 |
+
"""Returns 'hybrid', 'bigram-only', or 'unavailable'."""
|
| 303 |
+
if self.gpt2_model and self.unigrams:
|
| 304 |
+
return "hybrid"
|
| 305 |
+
elif self.unigrams:
|
| 306 |
+
return "bigram-only"
|
| 307 |
+
return "unavailable"
|