youssefreda9 commited on
Commit
d27ee08
·
1 Parent(s): 7e24e70

feat(NLP-4): AutoComplete integration — hybrid bigram + GPT-2

Browse files

COMPLETELY 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 CHANGED
@@ -158,7 +158,7 @@ def health_check():
158
  'models': {
159
  'summarization': summarization_model is not None,
160
  'spelling': _spelling_available(),
161
- 'autocomplete': False,
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
- Expected JSON payload:
 
 
 
 
 
 
 
414
  {
415
- "text": "Arabic text prefix",
416
- "n": 5 (number of suggestions, optional)
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
- text = data.get('text', '').strip()
431
  n = int(data.get('n', 5))
432
-
433
- if not text:
434
- return jsonify({'error': 'Text is required', 'status': 'error'}), 400
435
-
436
- logger.info(f"Getting autocomplete suggestions for: {text[:50]}...")
437
- if USE_HF_API:
438
- suggestions = hf_autocomplete(text, n=n)
439
- else:
440
- suggestions = autocomplete_model.predict(text, n=n)
441
- logger.info(f"Autocomplete suggestions (n={n}): {suggestions}")
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
- 'error': 'An error occurred during autocomplete.',
453
- 'status': 'error',
454
- 'details': str(e) if app.debug else None
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'])
src/css/components.css CHANGED
@@ -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
+ }
src/index.html CHANGED
@@ -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>
src/js/autocomplete.js ADDED
@@ -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
+ })();
src/nlp/autocomplete/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # AutoComplete package — completely independent from the correction pipeline
src/nlp/autocomplete/autocomplete_rules.py ADDED
@@ -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()
src/nlp/autocomplete/autocomplete_service.py ADDED
@@ -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"