youssefreda9 commited on
Commit
7b67137
·
1 Parent(s): 85a2596

feat: dialect-to-MSA conversion — backend API + frontend + Docker model cache

Browse files
Dockerfile CHANGED
@@ -59,6 +59,18 @@ EncoderDecoderModel.from_pretrained(repo); \
59
  print('PuncAra-v1 cached!'); \
60
  "
61
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  # Copy application code
63
  COPY src/ ./src/
64
  COPY quran.py ./
 
59
  print('PuncAra-v1 cached!'); \
60
  "
61
 
62
+ # 5. Dialect-to-MSA model (mT5, float16)
63
+ RUN python -c "\
64
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM; \
65
+ import torch; \
66
+ repo = 'bayan10/dialect-to-msa-model'; \
67
+ print('Downloading dialect tokenizer...'); \
68
+ AutoTokenizer.from_pretrained(repo); \
69
+ print('Downloading dialect model (float16)...'); \
70
+ AutoModelForSeq2SeqLM.from_pretrained(repo, torch_dtype=torch.float16); \
71
+ print('Dialect model cached!'); \
72
+ "
73
+
74
  # Copy application code
75
  COPY src/ ./src/
76
  COPY quran.py ./
src/app.py CHANGED
@@ -174,7 +174,8 @@ def health_check():
174
  'spelling': _spelling_available(),
175
  'autocomplete': _autocomplete_available(),
176
  'grammar': _grammar_available(),
177
- 'punctuation': _punctuation_available()
 
178
  },
179
  'note': 'Free tier: summarization local, other models return input unchanged',
180
  'supabase': {
@@ -193,7 +194,8 @@ def health_check():
193
  'spelling': spelling_model is not None,
194
  'autocomplete': autocomplete_model is not None,
195
  'grammar': grammar_model is not None,
196
- 'punctuation': punctuation_model is not None
 
197
  },
198
  'supabase': {
199
  'configured': bool(SUPABASE_URL and SUPABASE_ANON_KEY),
@@ -277,6 +279,15 @@ def _autocomplete_available():
277
  return False
278
 
279
 
 
 
 
 
 
 
 
 
 
280
  @app.route('/api/spelling', methods=['POST'])
281
  def spelling_correction():
282
  """
@@ -1073,6 +1084,71 @@ def _is_orthographic_variant(word1: str, word2: str) -> bool:
1073
  return diff_count > 0 # At least one orthographic difference
1074
 
1075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1076
  @app.route('/api/quran', methods=['POST'])
1077
  def quran_verify():
1078
  """
 
174
  'spelling': _spelling_available(),
175
  'autocomplete': _autocomplete_available(),
176
  'grammar': _grammar_available(),
177
+ 'punctuation': _punctuation_available(),
178
+ 'dialect': _dialect_available()
179
  },
180
  'note': 'Free tier: summarization local, other models return input unchanged',
181
  'supabase': {
 
194
  'spelling': spelling_model is not None,
195
  'autocomplete': autocomplete_model is not None,
196
  'grammar': grammar_model is not None,
197
+ 'punctuation': punctuation_model is not None,
198
+ 'dialect': _dialect_available()
199
  },
200
  'supabase': {
201
  'configured': bool(SUPABASE_URL and SUPABASE_ANON_KEY),
 
279
  return False
280
 
281
 
282
+ def _dialect_available():
283
+ """Check if dialect model is loaded (without triggering lazy load)."""
284
+ try:
285
+ from nlp.dialect.dialect_service import is_loaded
286
+ return is_loaded()
287
+ except Exception:
288
+ return False
289
+
290
+
291
  @app.route('/api/spelling', methods=['POST'])
292
  def spelling_correction():
293
  """
 
1084
  return diff_count > 0 # At least one orthographic difference
1085
 
1086
 
1087
+ @app.route('/api/dialect', methods=['POST'])
1088
+ def convert_dialect():
1089
+ """
1090
+ Convert dialect Arabic text to Modern Standard Arabic (MSA).
1091
+
1092
+ Request JSON:
1093
+ {
1094
+ "text": "عايز اشتكي من موظف في فرعكم"
1095
+ }
1096
+
1097
+ Response JSON:
1098
+ {
1099
+ "status": "success",
1100
+ "original_text": "...",
1101
+ "converted_text": "..."
1102
+ }
1103
+ """
1104
+ try:
1105
+ if not request.is_json:
1106
+ return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400
1107
+
1108
+ data = request.get_json()
1109
+ text = data.get('text', '').strip()
1110
+
1111
+ if not text:
1112
+ return jsonify({'error': 'Text is required', 'status': 'error'}), 400
1113
+
1114
+ if len(text) > MAX_TEXT_LENGTH:
1115
+ return jsonify({
1116
+ 'error': f'Text too long. Maximum {MAX_TEXT_LENGTH} characters.',
1117
+ 'status': 'error'
1118
+ }), 400
1119
+
1120
+ logger.info(f"[DIALECT] Conversion request: text_length={len(text)}")
1121
+
1122
+ from nlp.dialect.dialect_service import get_dialect_model
1123
+ converter = get_dialect_model()
1124
+ t0 = time.time()
1125
+ result = converter.convert(text)
1126
+ elapsed = int((time.time() - t0) * 1000)
1127
+
1128
+ logger.info(f"[DIALECT] {elapsed}ms | input='{text[:80]}' | output='{result[:80]}'")
1129
+
1130
+ return jsonify({
1131
+ 'original_text': text,
1132
+ 'converted_text': result,
1133
+ 'status': 'success'
1134
+ }), 200
1135
+
1136
+ except RuntimeError as e:
1137
+ logger.error(f"Dialect model error: {e}")
1138
+ return jsonify({
1139
+ 'error': f'Dialect model unavailable: {str(e)[:200]}',
1140
+ 'status': 'error'
1141
+ }), 503
1142
+ except Exception as e:
1143
+ logger.error(f"Error during dialect conversion: {e}")
1144
+ logger.error(traceback.format_exc())
1145
+ return jsonify({
1146
+ 'error': 'An error occurred during dialect conversion.',
1147
+ 'status': 'error',
1148
+ 'details': str(e) if app.debug else None
1149
+ }), 500
1150
+
1151
+
1152
  @app.route('/api/quran', methods=['POST'])
1153
  def quran_verify():
1154
  """
src/index.html CHANGED
@@ -865,7 +865,10 @@
865
  <div id="dialect-result-card" class="is-hidden" style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem;">
866
  <div class="flex items-center justify-between mb-3">
867
  <h3 class="text-base font-bold" style="color: var(--color-primary);">✓ النص بالعربية الفصحى</h3>
868
- <button onclick="copyDialectResult()" class="btn-ghost text-sm" type="button">نسخ</button>
 
 
 
869
  </div>
870
  <div id="dialect-result" class="text-right text-lg editor-content" dir="rtl" style="line-height: 2;"></div>
871
  </div>
@@ -1192,8 +1195,57 @@
1192
  else if (tab === 'summarize') { summarizeTab.classList.add('active'); summarizeArea.classList.remove('is-hidden'); if(formatToolbar)formatToolbar.style.display='none'; }
1193
  else if (tab === 'dialect') { dialectTab.classList.add('active'); dialectArea.classList.remove('is-hidden'); if(formatToolbar)formatToolbar.style.display='none'; }
1194
  }
1195
- function convertDialect(){var i=document.getElementById('dialect-input').value.trim();if(!i){if(typeof showToast==='function')showToast('الرجاء كتابة نص أولاً');return;}var r=document.getElementById('dialect-result-card');var d=document.getElementById('dialect-result');d.innerHTML='<p class="text-secondary text-center">⏳ جاري التحويل...</p>';r.classList.remove('is-hidden');setTimeout(function(){d.innerHTML='<p class="text-secondary text-center">🚧 هذه الميزة قيد التطوير — ستتوفر قريبًا</p>';},1000);}
1196
- function copyDialectResult(){var t=document.getElementById('dialect-result');if(t){navigator.clipboard.writeText(t.innerText);if(typeof showToast==='function')showToast('✓ تم النسخ');}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1197
 
1198
  /* ═══════════════════════════════════════════
1199
  Quran Verification & Translation
 
865
  <div id="dialect-result-card" class="is-hidden" style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem;">
866
  <div class="flex items-center justify-between mb-3">
867
  <h3 class="text-base font-bold" style="color: var(--color-primary);">✓ النص بالعربية الفصحى</h3>
868
+ <div class="flex items-center gap-2">
869
+ <button onclick="copyDialectResult()" class="quran-copy-btn" type="button" title="نسخ">📋</button>
870
+ <button id="dialect-apply-btn" onclick="applyDialectResult()" class="quran-apply-btn is-hidden" type="button">تطبيق في المحرر ✓</button>
871
+ </div>
872
  </div>
873
  <div id="dialect-result" class="text-right text-lg editor-content" dir="rtl" style="line-height: 2;"></div>
874
  </div>
 
1195
  else if (tab === 'summarize') { summarizeTab.classList.add('active'); summarizeArea.classList.remove('is-hidden'); if(formatToolbar)formatToolbar.style.display='none'; }
1196
  else if (tab === 'dialect') { dialectTab.classList.add('active'); dialectArea.classList.remove('is-hidden'); if(formatToolbar)formatToolbar.style.display='none'; }
1197
  }
1198
+ let _dialectResult = '';
1199
+ async function convertDialect() {
1200
+ var input = document.getElementById('dialect-input').value.trim();
1201
+ if (!input) { if (typeof showToast === 'function') showToast('الرجاء كتابة نص أولاً'); return; }
1202
+
1203
+ var resultCard = document.getElementById('dialect-result-card');
1204
+ var resultDiv = document.getElementById('dialect-result');
1205
+ var applyBtn = document.getElementById('dialect-apply-btn');
1206
+ resultDiv.innerHTML = '<p class="text-secondary text-center">⏳ جاري التحويل...</p>';
1207
+ resultCard.classList.remove('is-hidden');
1208
+ if (applyBtn) applyBtn.classList.add('is-hidden');
1209
+
1210
+ try {
1211
+ var resp = await fetch('/api/dialect', {
1212
+ method: 'POST',
1213
+ headers: { 'Content-Type': 'application/json' },
1214
+ body: JSON.stringify({ text: input })
1215
+ });
1216
+ var data = await resp.json();
1217
+ if (data.status === 'success' && data.converted_text) {
1218
+ _dialectResult = data.converted_text;
1219
+ var esc = function(t) { return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); };
1220
+ resultDiv.innerHTML = '<p style="font-size:20px; line-height:2; direction:rtl; text-align:center;">' + esc(data.converted_text) + '</p>';
1221
+ if (applyBtn) applyBtn.classList.remove('is-hidden');
1222
+ } else {
1223
+ _dialectResult = '';
1224
+ resultDiv.innerHTML = '<p class="text-secondary text-center">' + (data.error || 'حدث خطأ أثناء التحويل') + '</p>';
1225
+ }
1226
+ } catch (err) {
1227
+ _dialectResult = '';
1228
+ resultDiv.innerHTML = '<p class="text-secondary text-center">حدث خطأ — تأكد من الاتصال</p>';
1229
+ }
1230
+ }
1231
+
1232
+ function copyDialectResult() {
1233
+ if (!_dialectResult) { var t = document.getElementById('dialect-result'); if (t) navigator.clipboard.writeText(t.innerText); }
1234
+ else navigator.clipboard.writeText(_dialectResult);
1235
+ if (typeof showToast === 'function') showToast('✓ تم النسخ');
1236
+ }
1237
+
1238
+ function applyDialectResult() {
1239
+ if (!_dialectResult) return;
1240
+ var editor = document.getElementById('editor-container');
1241
+ if (!editor) return;
1242
+ pushUndoState();
1243
+ var esc = function(t) { return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); };
1244
+ editor.innerHTML = esc(_dialectResult);
1245
+ editor.dispatchEvent(new Event('input', { bubbles: true }));
1246
+ switchTab('write');
1247
+ if (typeof showToast === 'function') showToast('✓ تم تطبيق النص الفصيح في المحرر');
1248
+ }
1249
 
1250
  /* ═══════════════════════════════════════════
1251
  Quran Verification & Translation
src/nlp/dialect/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Dialect-to-MSA conversion module
src/nlp/dialect/dialect_service.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dialect-to-MSA (Modern Standard Arabic) conversion service.
3
+
4
+ Uses bayan10/dialect-to-msa-model (mT5 300M) to convert colloquial
5
+ Arabic dialects (Egyptian, Gulf, Levantine, Maghrebi) to formal MSA.
6
+
7
+ Singleton pattern — lazy-loads the model on first request to avoid
8
+ blocking server startup.
9
+ """
10
+
11
+ import logging
12
+ import torch
13
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _instance = None
18
+
19
+
20
+ class DialectConverter:
21
+ """Converts dialect Arabic text to Modern Standard Arabic (MSA)."""
22
+
23
+ PREFIX = "حوّل إلى الفصحى: "
24
+ REPO_ID = "bayan10/dialect-to-msa-model"
25
+ MAX_INPUT_LENGTH = 128
26
+ MAX_OUTPUT_LENGTH = 128
27
+
28
+ def __init__(self):
29
+ self.device = "cpu"
30
+ logger.info(f"[DIALECT] Loading model from '{self.REPO_ID}'...")
31
+
32
+ self.tokenizer = AutoTokenizer.from_pretrained(self.REPO_ID)
33
+ self.model = AutoModelForSeq2SeqLM.from_pretrained(
34
+ self.REPO_ID, torch_dtype=torch.float16
35
+ ).to(self.device)
36
+ self.model.eval()
37
+
38
+ logger.info("[DIALECT] Model loaded successfully (float16).")
39
+
40
+ def convert(self, dialect_text: str, num_beams: int = 4) -> str:
41
+ """Convert a single dialect sentence to MSA."""
42
+ if not dialect_text or not dialect_text.strip():
43
+ return dialect_text
44
+
45
+ input_text = self.PREFIX + dialect_text.strip()
46
+ inputs = self.tokenizer(
47
+ input_text,
48
+ return_tensors="pt",
49
+ max_length=self.MAX_INPUT_LENGTH,
50
+ truncation=True,
51
+ ).to(self.device)
52
+
53
+ with torch.no_grad():
54
+ outputs = self.model.generate(
55
+ **inputs,
56
+ max_length=self.MAX_OUTPUT_LENGTH,
57
+ num_beams=num_beams,
58
+ early_stopping=True,
59
+ no_repeat_ngram_size=3,
60
+ )
61
+
62
+ result = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
63
+ return result
64
+
65
+ def is_ready(self) -> bool:
66
+ """Check if the model is loaded and ready."""
67
+ return self.model is not None and self.tokenizer is not None
68
+
69
+
70
+ def get_dialect_model() -> DialectConverter:
71
+ """Get or create the singleton DialectConverter instance."""
72
+ global _instance
73
+ if _instance is None:
74
+ _instance = DialectConverter()
75
+ return _instance
76
+
77
+
78
+ def is_loaded() -> bool:
79
+ """Check if the dialect model is loaded (without triggering lazy load)."""
80
+ return _instance is not None and _instance.is_ready()