boffire commited on
Commit
c948ffa
·
verified ·
1 Parent(s): 3252379

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +179 -79
app.py CHANGED
@@ -3,6 +3,7 @@
3
  Kabyle Translation Hub - Hugging Face Spaces Edition
4
  Simple translation interface: MarianMT vs LibreTranslate
5
  Users choose their preferred translation - no metrics, no scores.
 
6
  """
7
 
8
  import warnings
@@ -34,33 +35,35 @@ model = None
34
  tokenizer = None
35
  device = None
36
 
 
37
  def load_model():
38
  """Load MarianMT model once and cache it"""
39
  global model, tokenizer, device
40
-
41
  if model is None:
42
  print("Loading MarianMT model...")
43
  device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
44
-
45
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=False)
46
  model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).to(device).eval()
47
-
48
  print(f"Model loaded successfully on {device}")
49
-
50
  return model, tokenizer, device
51
 
 
52
  def translate_marian(text):
53
  """Translate using MarianMT with multiple alternatives"""
54
  if not text or not text.strip():
55
  return ["Please enter text to translate"]
56
-
57
  try:
58
  model, tokenizer, device = load_model()
59
-
60
  # Prepare inputs
61
  inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512)
62
  inputs = {k: v.to(device) for k, v in inputs.items()}
63
-
64
  with torch.no_grad():
65
  # Simple beam search without group beam search
66
  outputs = model.generate(
@@ -71,21 +74,22 @@ def translate_marian(text):
71
  early_stopping=True,
72
  do_sample=False,
73
  )
74
-
75
  translations = []
76
  for output in outputs:
77
  trans = tokenizer.decode(output, skip_special_tokens=True)
78
  if trans and trans not in translations:
79
  translations.append(trans)
80
-
81
  return translations if translations else ["[Error: No translation generated]"]
82
-
83
  except Exception as e:
84
  print(f"MarianMT translation error: {e}")
85
  import traceback
86
  traceback.print_exc()
87
  return [f"[Error: {str(e)}]"]
88
 
 
89
  def translate_libre_variant(text, variant_code):
90
  """Translate using a specific LibreTranslate variant"""
91
  try:
@@ -101,12 +105,13 @@ def translate_libre_variant(text, variant_code):
101
  except Exception as e:
102
  return {"success": False, "text": f"[Error: {str(e)[:50]}]"}
103
 
 
104
  def translate_libre_all_variants(text):
105
  """Translate using all LibreTranslate variants in parallel"""
106
  results = {}
107
  with ThreadPoolExecutor(max_workers=4) as executor:
108
  future_to_name = {
109
- executor.submit(translate_libre_variant, text, code): name
110
  for name, code in KABYLE_VARIANTS.items()
111
  }
112
  for future in as_completed(future_to_name, timeout=15):
@@ -117,6 +122,7 @@ def translate_libre_all_variants(text):
117
  results[name] = {"success": False, "text": f"[Error: {e}]"}
118
  return results
119
 
 
120
  HTML_TEMPLATE = """
121
  <!DOCTYPE html>
122
  <html lang="en">
@@ -138,9 +144,9 @@ HTML_TEMPLATE = """
138
  --radius: 12px;
139
  --radius-sm: 8px;
140
  }
141
-
142
  * { margin: 0; padding: 0; box-sizing: border-box; }
143
-
144
  body {
145
  font-family: system-ui, -apple-system, sans-serif;
146
  background: linear-gradient(135deg, var(--bg-primary) 0%, #1a1a2e 100%);
@@ -148,17 +154,17 @@ HTML_TEMPLATE = """
148
  min-height: 100vh;
149
  padding: 20px;
150
  }
151
-
152
  .container {
153
  max-width: 1000px;
154
  margin: 0 auto;
155
  }
156
-
157
  header {
158
  text-align: center;
159
  padding: 40px 20px;
160
  }
161
-
162
  header h1 {
163
  font-size: 2.5rem;
164
  font-weight: 800;
@@ -167,12 +173,12 @@ HTML_TEMPLATE = """
167
  -webkit-text-fill-color: transparent;
168
  margin-bottom: 10px;
169
  }
170
-
171
  header p {
172
  color: var(--text-secondary);
173
  font-size: 1.1rem;
174
  }
175
-
176
  .input-card {
177
  background: var(--bg-secondary);
178
  border-radius: var(--radius);
@@ -180,13 +186,13 @@ HTML_TEMPLATE = """
180
  margin-bottom: 24px;
181
  border: 1px solid var(--border);
182
  }
183
-
184
  .input-group {
185
  display: flex;
186
  flex-direction: column;
187
  gap: 16px;
188
  }
189
-
190
  textarea {
191
  width: 100%;
192
  padding: 16px;
@@ -200,16 +206,16 @@ HTML_TEMPLATE = """
200
  font-family: inherit;
201
  transition: border-color 0.2s;
202
  }
203
-
204
  textarea:focus {
205
  outline: none;
206
  border-color: var(--accent-primary);
207
  }
208
-
209
  textarea::placeholder {
210
  color: var(--text-secondary);
211
  }
212
-
213
  .btn-primary {
214
  background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
215
  color: white;
@@ -225,31 +231,31 @@ HTML_TEMPLATE = """
225
  justify-content: center;
226
  gap: 8px;
227
  }
228
-
229
  .btn-primary:hover {
230
  transform: translateY(-2px);
231
  box-shadow: 0 10px 30px rgba(99, 102, 241, 0.3);
232
  }
233
-
234
  .btn-primary:disabled {
235
  opacity: 0.6;
236
  cursor: not-allowed;
237
  transform: none;
238
  }
239
-
240
  .results-container {
241
  display: flex;
242
  flex-direction: column;
243
  gap: 20px;
244
  }
245
-
246
  .engine-card {
247
  background: var(--bg-secondary);
248
  border-radius: var(--radius);
249
  border: 1px solid var(--border);
250
  overflow: hidden;
251
  }
252
-
253
  .engine-header {
254
  padding: 20px 24px;
255
  background: var(--bg-tertiary);
@@ -258,7 +264,7 @@ HTML_TEMPLATE = """
258
  align-items: center;
259
  justify-content: space-between;
260
  }
261
-
262
  .engine-title {
263
  display: flex;
264
  align-items: center;
@@ -266,7 +272,7 @@ HTML_TEMPLATE = """
266
  font-size: 1.1rem;
267
  font-weight: 600;
268
  }
269
-
270
  .engine-badge {
271
  font-size: 0.75rem;
272
  padding: 4px 12px;
@@ -275,11 +281,11 @@ HTML_TEMPLATE = """
275
  color: var(--text-secondary);
276
  border: 1px solid var(--border);
277
  }
278
-
279
  .translation-list {
280
  list-style: none;
281
  }
282
-
283
  .translation-item {
284
  padding: 20px 24px;
285
  border-bottom: 1px solid var(--border);
@@ -290,45 +296,45 @@ HTML_TEMPLATE = """
290
  justify-content: space-between;
291
  gap: 16px;
292
  }
293
-
294
  .translation-item:last-child {
295
  border-bottom: none;
296
  }
297
-
298
  .translation-item:hover {
299
  background: rgba(99, 102, 241, 0.1);
300
  }
301
-
302
  .translation-item.selected {
303
  background: rgba(16, 185, 129, 0.15);
304
  border-left: 4px solid var(--accent-success);
305
  }
306
-
307
  .translation-text {
308
  flex: 1;
309
  font-size: 1.05rem;
310
  line-height: 1.6;
311
  color: var(--text-primary);
312
  }
313
-
314
  .copy-icon {
315
  opacity: 0;
316
  color: var(--accent-primary);
317
  transition: opacity 0.2s;
318
  flex-shrink: 0;
319
  }
320
-
321
  .translation-item:hover .copy-icon {
322
  opacity: 1;
323
  }
324
-
325
  .variant-grid {
326
  display: grid;
327
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
328
  gap: 1px;
329
  background: var(--border);
330
  }
331
-
332
  .variant-item {
333
  background: var(--bg-secondary);
334
  padding: 16px 20px;
@@ -339,22 +345,22 @@ HTML_TEMPLATE = """
339
  gap: 8px;
340
  position: relative;
341
  }
342
-
343
  .variant-item:hover {
344
  background: rgba(99, 102, 241, 0.1);
345
  }
346
-
347
  .variant-item.selected {
348
  background: rgba(16, 185, 129, 0.15);
349
  box-shadow: inset 4px 0 0 var(--accent-success);
350
  }
351
-
352
  .variant-header {
353
  display: flex;
354
  align-items: center;
355
  justify-content: space-between;
356
  }
357
-
358
  .variant-name {
359
  font-size: 0.75rem;
360
  color: var(--text-secondary);
@@ -362,7 +368,7 @@ HTML_TEMPLATE = """
362
  letter-spacing: 0.5px;
363
  font-weight: 600;
364
  }
365
-
366
  .variant-code {
367
  font-size: 0.7rem;
368
  color: var(--text-secondary);
@@ -371,23 +377,23 @@ HTML_TEMPLATE = """
371
  padding: 2px 8px;
372
  border-radius: 4px;
373
  }
374
-
375
  .variant-text {
376
  font-size: 1rem;
377
  color: var(--text-primary);
378
  line-height: 1.5;
379
  }
380
-
381
  .variant-item.error {
382
  opacity: 0.6;
383
  }
384
-
385
  .variant-item.error .variant-text {
386
  color: #ef4444;
387
  font-family: monospace;
388
  font-size: 0.85rem;
389
  }
390
-
391
  .source-display {
392
  background: var(--bg-tertiary);
393
  padding: 20px 24px;
@@ -395,7 +401,7 @@ HTML_TEMPLATE = """
395
  margin-bottom: 20px;
396
  border: 1px solid var(--border);
397
  }
398
-
399
  .source-label {
400
  font-size: 0.75rem;
401
  color: var(--text-secondary);
@@ -403,24 +409,24 @@ HTML_TEMPLATE = """
403
  letter-spacing: 0.5px;
404
  margin-bottom: 8px;
405
  }
406
-
407
  .source-text {
408
  font-size: 1.1rem;
409
  color: var(--text-primary);
410
  line-height: 1.6;
411
  }
412
-
413
  .check-icon {
414
  color: var(--accent-success);
415
  opacity: 0;
416
  transition: opacity 0.2s;
417
  }
418
-
419
  .translation-item.selected .check-icon,
420
  .variant-item.selected .check-icon {
421
  opacity: 1;
422
  }
423
-
424
  .toast {
425
  position: fixed;
426
  bottom: 24px;
@@ -436,12 +442,12 @@ HTML_TEMPLATE = """
436
  z-index: 1000;
437
  box-shadow: 0 10px 30px rgba(0,0,0,0.3);
438
  }
439
-
440
  .toast.show {
441
  transform: translateX(-50%) translateY(0);
442
  opacity: 1;
443
  }
444
-
445
  .loading {
446
  display: inline-block;
447
  width: 18px;
@@ -451,29 +457,43 @@ HTML_TEMPLATE = """
451
  border-top-color: white;
452
  animation: spin 0.8s linear infinite;
453
  }
454
-
455
  @keyframes spin {
456
  to { transform: rotate(360deg); }
457
  }
458
-
459
  .empty-state {
460
  text-align: center;
461
  padding: 60px 20px;
462
  color: var(--text-secondary);
463
  }
464
-
465
  .empty-state svg {
466
  width: 64px;
467
  height: 64px;
468
  margin-bottom: 16px;
469
  opacity: 0.5;
470
  }
471
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  @media (max-width: 640px) {
473
  header h1 {
474
  font-size: 1.75rem;
475
  }
476
-
477
  .variant-grid {
478
  grid-template-columns: 1fr;
479
  }
@@ -486,14 +506,14 @@ HTML_TEMPLATE = """
486
  <h1>English → Kabyle</h1>
487
  <p>Choose the translation that suits you best</p>
488
  </header>
489
-
490
  <div class="input-card">
491
  <form method="POST" id="translateForm">
492
  <div class="input-group">
493
- <textarea
494
- name="text"
495
  id="inputText"
496
- placeholder="Enter English text to translate..."
497
  required
498
  autocomplete="off"
499
  >{{ request.form.get('text', '') }}</textarea>
@@ -507,13 +527,13 @@ HTML_TEMPLATE = """
507
  </div>
508
  </form>
509
  </div>
510
-
511
  {% if source_text %}
512
  <div class="source-display">
513
  <div class="source-label">Source Text</div>
514
  <div class="source-text">{{ source_text }}</div>
515
  </div>
516
-
517
  <div class="results-container">
518
  <!-- MarianMT Results -->
519
  <div class="engine-card">
@@ -542,7 +562,7 @@ HTML_TEMPLATE = """
542
  {% endfor %}
543
  </ul>
544
  </div>
545
-
546
  <!-- LibreTranslate Results -->
547
  <div class="engine-card">
548
  <div class="engine-header">
@@ -590,13 +610,17 @@ HTML_TEMPLATE = """
590
  <p>Enter text above and click Translate to see results</p>
591
  </div>
592
  {% endif %}
 
 
 
 
593
  </div>
594
-
595
  <div class="toast" id="toast">Copied to clipboard!</div>
596
-
597
  <script>
598
  let selectedText = '';
599
-
600
  function selectTranslation(element, text) {
601
  element.parentElement.querySelectorAll('.translation-item').forEach(item => {
602
  item.classList.remove('selected');
@@ -605,7 +629,7 @@ HTML_TEMPLATE = """
605
  selectedText = text;
606
  copyText(text, false);
607
  }
608
-
609
  function selectVariant(element, text) {
610
  document.querySelectorAll('.variant-item').forEach(item => {
611
  item.classList.remove('selected');
@@ -614,7 +638,7 @@ HTML_TEMPLATE = """
614
  selectedText = text;
615
  copyText(text, false);
616
  }
617
-
618
  async function copyText(text, showToast = true) {
619
  try {
620
  await navigator.clipboard.writeText(text);
@@ -633,14 +657,14 @@ HTML_TEMPLATE = """
633
  }
634
  }
635
  }
636
-
637
  function showToastMessage(message) {
638
  const toast = document.getElementById('toast');
639
  toast.textContent = message;
640
  toast.classList.add('show');
641
  setTimeout(() => toast.classList.remove('show'), 2000);
642
  }
643
-
644
  document.getElementById('translateForm').addEventListener('submit', function() {
645
  const btn = document.getElementById('submitBtn');
646
  btn.disabled = true;
@@ -653,12 +677,13 @@ HTML_TEMPLATE = """
653
 
654
  app = Flask(__name__)
655
 
 
656
  @app.route("/", methods=["GET", "POST"])
657
  def index():
658
  marian = []
659
  libre = {}
660
  source_text = ""
661
-
662
  if request.method == "POST":
663
  source_text = request.form.get("text", "").strip()
664
  if source_text:
@@ -667,14 +692,88 @@ def index():
667
  for name, data in libre_results.items():
668
  data['code'] = KABYLE_VARIANTS[name]
669
  libre[name] = data
670
-
671
  return render_template_string(
672
- HTML_TEMPLATE,
673
- marian=marian,
674
  libre=libre,
675
  source_text=source_text
676
  )
677
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678
  @app.route("/health")
679
  def health():
680
  return jsonify({
@@ -682,6 +781,7 @@ def health():
682
  "model_loaded": model is not None
683
  })
684
 
 
685
  if __name__ == "__main__":
686
  port = int(os.environ.get("PORT", 7860))
687
  app.run(host="0.0.0.0", port=port, debug=False)
 
3
  Kabyle Translation Hub - Hugging Face Spaces Edition
4
  Simple translation interface: MarianMT vs LibreTranslate
5
  Users choose their preferred translation - no metrics, no scores.
6
+ Also exposes a JSON API for programmatic access.
7
  """
8
 
9
  import warnings
 
35
  tokenizer = None
36
  device = None
37
 
38
+
39
  def load_model():
40
  """Load MarianMT model once and cache it"""
41
  global model, tokenizer, device
42
+
43
  if model is None:
44
  print("Loading MarianMT model...")
45
  device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
46
+
47
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=False)
48
  model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).to(device).eval()
49
+
50
  print(f"Model loaded successfully on {device}")
51
+
52
  return model, tokenizer, device
53
 
54
+
55
  def translate_marian(text):
56
  """Translate using MarianMT with multiple alternatives"""
57
  if not text or not text.strip():
58
  return ["Please enter text to translate"]
59
+
60
  try:
61
  model, tokenizer, device = load_model()
62
+
63
  # Prepare inputs
64
  inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512)
65
  inputs = {k: v.to(device) for k, v in inputs.items()}
66
+
67
  with torch.no_grad():
68
  # Simple beam search without group beam search
69
  outputs = model.generate(
 
74
  early_stopping=True,
75
  do_sample=False,
76
  )
77
+
78
  translations = []
79
  for output in outputs:
80
  trans = tokenizer.decode(output, skip_special_tokens=True)
81
  if trans and trans not in translations:
82
  translations.append(trans)
83
+
84
  return translations if translations else ["[Error: No translation generated]"]
85
+
86
  except Exception as e:
87
  print(f"MarianMT translation error: {e}")
88
  import traceback
89
  traceback.print_exc()
90
  return [f"[Error: {str(e)}]"]
91
 
92
+
93
  def translate_libre_variant(text, variant_code):
94
  """Translate using a specific LibreTranslate variant"""
95
  try:
 
105
  except Exception as e:
106
  return {"success": False, "text": f"[Error: {str(e)[:50]}]"}
107
 
108
+
109
  def translate_libre_all_variants(text):
110
  """Translate using all LibreTranslate variants in parallel"""
111
  results = {}
112
  with ThreadPoolExecutor(max_workers=4) as executor:
113
  future_to_name = {
114
+ executor.submit(translate_libre_variant, text, code): name
115
  for name, code in KABYLE_VARIANTS.items()
116
  }
117
  for future in as_completed(future_to_name, timeout=15):
 
122
  results[name] = {"success": False, "text": f"[Error: {e}]"}
123
  return results
124
 
125
+
126
  HTML_TEMPLATE = """
127
  <!DOCTYPE html>
128
  <html lang="en">
 
144
  --radius: 12px;
145
  --radius-sm: 8px;
146
  }
147
+
148
  * { margin: 0; padding: 0; box-sizing: border-box; }
149
+
150
  body {
151
  font-family: system-ui, -apple-system, sans-serif;
152
  background: linear-gradient(135deg, var(--bg-primary) 0%, #1a1a2e 100%);
 
154
  min-height: 100vh;
155
  padding: 20px;
156
  }
157
+
158
  .container {
159
  max-width: 1000px;
160
  margin: 0 auto;
161
  }
162
+
163
  header {
164
  text-align: center;
165
  padding: 40px 20px;
166
  }
167
+
168
  header h1 {
169
  font-size: 2.5rem;
170
  font-weight: 800;
 
173
  -webkit-text-fill-color: transparent;
174
  margin-bottom: 10px;
175
  }
176
+
177
  header p {
178
  color: var(--text-secondary);
179
  font-size: 1.1rem;
180
  }
181
+
182
  .input-card {
183
  background: var(--bg-secondary);
184
  border-radius: var(--radius);
 
186
  margin-bottom: 24px;
187
  border: 1px solid var(--border);
188
  }
189
+
190
  .input-group {
191
  display: flex;
192
  flex-direction: column;
193
  gap: 16px;
194
  }
195
+
196
  textarea {
197
  width: 100%;
198
  padding: 16px;
 
206
  font-family: inherit;
207
  transition: border-color 0.2s;
208
  }
209
+
210
  textarea:focus {
211
  outline: none;
212
  border-color: var(--accent-primary);
213
  }
214
+
215
  textarea::placeholder {
216
  color: var(--text-secondary);
217
  }
218
+
219
  .btn-primary {
220
  background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
221
  color: white;
 
231
  justify-content: center;
232
  gap: 8px;
233
  }
234
+
235
  .btn-primary:hover {
236
  transform: translateY(-2px);
237
  box-shadow: 0 10px 30px rgba(99, 102, 241, 0.3);
238
  }
239
+
240
  .btn-primary:disabled {
241
  opacity: 0.6;
242
  cursor: not-allowed;
243
  transform: none;
244
  }
245
+
246
  .results-container {
247
  display: flex;
248
  flex-direction: column;
249
  gap: 20px;
250
  }
251
+
252
  .engine-card {
253
  background: var(--bg-secondary);
254
  border-radius: var(--radius);
255
  border: 1px solid var(--border);
256
  overflow: hidden;
257
  }
258
+
259
  .engine-header {
260
  padding: 20px 24px;
261
  background: var(--bg-tertiary);
 
264
  align-items: center;
265
  justify-content: space-between;
266
  }
267
+
268
  .engine-title {
269
  display: flex;
270
  align-items: center;
 
272
  font-size: 1.1rem;
273
  font-weight: 600;
274
  }
275
+
276
  .engine-badge {
277
  font-size: 0.75rem;
278
  padding: 4px 12px;
 
281
  color: var(--text-secondary);
282
  border: 1px solid var(--border);
283
  }
284
+
285
  .translation-list {
286
  list-style: none;
287
  }
288
+
289
  .translation-item {
290
  padding: 20px 24px;
291
  border-bottom: 1px solid var(--border);
 
296
  justify-content: space-between;
297
  gap: 16px;
298
  }
299
+
300
  .translation-item:last-child {
301
  border-bottom: none;
302
  }
303
+
304
  .translation-item:hover {
305
  background: rgba(99, 102, 241, 0.1);
306
  }
307
+
308
  .translation-item.selected {
309
  background: rgba(16, 185, 129, 0.15);
310
  border-left: 4px solid var(--accent-success);
311
  }
312
+
313
  .translation-text {
314
  flex: 1;
315
  font-size: 1.05rem;
316
  line-height: 1.6;
317
  color: var(--text-primary);
318
  }
319
+
320
  .copy-icon {
321
  opacity: 0;
322
  color: var(--accent-primary);
323
  transition: opacity 0.2s;
324
  flex-shrink: 0;
325
  }
326
+
327
  .translation-item:hover .copy-icon {
328
  opacity: 1;
329
  }
330
+
331
  .variant-grid {
332
  display: grid;
333
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
334
  gap: 1px;
335
  background: var(--border);
336
  }
337
+
338
  .variant-item {
339
  background: var(--bg-secondary);
340
  padding: 16px 20px;
 
345
  gap: 8px;
346
  position: relative;
347
  }
348
+
349
  .variant-item:hover {
350
  background: rgba(99, 102, 241, 0.1);
351
  }
352
+
353
  .variant-item.selected {
354
  background: rgba(16, 185, 129, 0.15);
355
  box-shadow: inset 4px 0 0 var(--accent-success);
356
  }
357
+
358
  .variant-header {
359
  display: flex;
360
  align-items: center;
361
  justify-content: space-between;
362
  }
363
+
364
  .variant-name {
365
  font-size: 0.75rem;
366
  color: var(--text-secondary);
 
368
  letter-spacing: 0.5px;
369
  font-weight: 600;
370
  }
371
+
372
  .variant-code {
373
  font-size: 0.7rem;
374
  color: var(--text-secondary);
 
377
  padding: 2px 8px;
378
  border-radius: 4px;
379
  }
380
+
381
  .variant-text {
382
  font-size: 1rem;
383
  color: var(--text-primary);
384
  line-height: 1.5;
385
  }
386
+
387
  .variant-item.error {
388
  opacity: 0.6;
389
  }
390
+
391
  .variant-item.error .variant-text {
392
  color: #ef4444;
393
  font-family: monospace;
394
  font-size: 0.85rem;
395
  }
396
+
397
  .source-display {
398
  background: var(--bg-tertiary);
399
  padding: 20px 24px;
 
401
  margin-bottom: 20px;
402
  border: 1px solid var(--border);
403
  }
404
+
405
  .source-label {
406
  font-size: 0.75rem;
407
  color: var(--text-secondary);
 
409
  letter-spacing: 0.5px;
410
  margin-bottom: 8px;
411
  }
412
+
413
  .source-text {
414
  font-size: 1.1rem;
415
  color: var(--text-primary);
416
  line-height: 1.6;
417
  }
418
+
419
  .check-icon {
420
  color: var(--accent-success);
421
  opacity: 0;
422
  transition: opacity 0.2s;
423
  }
424
+
425
  .translation-item.selected .check-icon,
426
  .variant-item.selected .check-icon {
427
  opacity: 1;
428
  }
429
+
430
  .toast {
431
  position: fixed;
432
  bottom: 24px;
 
442
  z-index: 1000;
443
  box-shadow: 0 10px 30px rgba(0,0,0,0.3);
444
  }
445
+
446
  .toast.show {
447
  transform: translateX(-50%) translateY(0);
448
  opacity: 1;
449
  }
450
+
451
  .loading {
452
  display: inline-block;
453
  width: 18px;
 
457
  border-top-color: white;
458
  animation: spin 0.8s linear infinite;
459
  }
460
+
461
  @keyframes spin {
462
  to { transform: rotate(360deg); }
463
  }
464
+
465
  .empty-state {
466
  text-align: center;
467
  padding: 60px 20px;
468
  color: var(--text-secondary);
469
  }
470
+
471
  .empty-state svg {
472
  width: 64px;
473
  height: 64px;
474
  margin-bottom: 16px;
475
  opacity: 0.5;
476
  }
477
+
478
+ .api-note {
479
+ text-align: center;
480
+ margin-top: 32px;
481
+ color: var(--text-secondary);
482
+ font-size: 0.85rem;
483
+ }
484
+
485
+ .api-note code {
486
+ background: var(--bg-tertiary);
487
+ padding: 2px 8px;
488
+ border-radius: 4px;
489
+ font-family: monospace;
490
+ }
491
+
492
  @media (max-width: 640px) {
493
  header h1 {
494
  font-size: 1.75rem;
495
  }
496
+
497
  .variant-grid {
498
  grid-template-columns: 1fr;
499
  }
 
506
  <h1>English → Kabyle</h1>
507
  <p>Choose the translation that suits you best</p>
508
  </header>
509
+
510
  <div class="input-card">
511
  <form method="POST" id="translateForm">
512
  <div class="input-group">
513
+ <textarea
514
+ name="text"
515
  id="inputText"
516
+ placeholder="Enter English text to translate..."
517
  required
518
  autocomplete="off"
519
  >{{ request.form.get('text', '') }}</textarea>
 
527
  </div>
528
  </form>
529
  </div>
530
+
531
  {% if source_text %}
532
  <div class="source-display">
533
  <div class="source-label">Source Text</div>
534
  <div class="source-text">{{ source_text }}</div>
535
  </div>
536
+
537
  <div class="results-container">
538
  <!-- MarianMT Results -->
539
  <div class="engine-card">
 
562
  {% endfor %}
563
  </ul>
564
  </div>
565
+
566
  <!-- LibreTranslate Results -->
567
  <div class="engine-card">
568
  <div class="engine-header">
 
610
  <p>Enter text above and click Translate to see results</p>
611
  </div>
612
  {% endif %}
613
+
614
+ <div class="api-note">
615
+ API available at <code>POST /api/translate</code> — see <code>/api/variants</code> for variant codes.
616
+ </div>
617
  </div>
618
+
619
  <div class="toast" id="toast">Copied to clipboard!</div>
620
+
621
  <script>
622
  let selectedText = '';
623
+
624
  function selectTranslation(element, text) {
625
  element.parentElement.querySelectorAll('.translation-item').forEach(item => {
626
  item.classList.remove('selected');
 
629
  selectedText = text;
630
  copyText(text, false);
631
  }
632
+
633
  function selectVariant(element, text) {
634
  document.querySelectorAll('.variant-item').forEach(item => {
635
  item.classList.remove('selected');
 
638
  selectedText = text;
639
  copyText(text, false);
640
  }
641
+
642
  async function copyText(text, showToast = true) {
643
  try {
644
  await navigator.clipboard.writeText(text);
 
657
  }
658
  }
659
  }
660
+
661
  function showToastMessage(message) {
662
  const toast = document.getElementById('toast');
663
  toast.textContent = message;
664
  toast.classList.add('show');
665
  setTimeout(() => toast.classList.remove('show'), 2000);
666
  }
667
+
668
  document.getElementById('translateForm').addEventListener('submit', function() {
669
  const btn = document.getElementById('submitBtn');
670
  btn.disabled = true;
 
677
 
678
  app = Flask(__name__)
679
 
680
+
681
  @app.route("/", methods=["GET", "POST"])
682
  def index():
683
  marian = []
684
  libre = {}
685
  source_text = ""
686
+
687
  if request.method == "POST":
688
  source_text = request.form.get("text", "").strip()
689
  if source_text:
 
692
  for name, data in libre_results.items():
693
  data['code'] = KABYLE_VARIANTS[name]
694
  libre[name] = data
695
+
696
  return render_template_string(
697
+ HTML_TEMPLATE,
698
+ marian=marian,
699
  libre=libre,
700
  source_text=source_text
701
  )
702
 
703
+
704
+ @app.route("/api/translate", methods=["GET", "POST"])
705
+ def api_translate():
706
+ """
707
+ JSON API for translation.
708
+
709
+ POST body (application/json):
710
+ {
711
+ "text": "Hello world",
712
+ "engines": ["marian", "libre"], // optional, default both
713
+ "variants": ["kab", "kab_kab"] // optional, default all LibreTranslate variants
714
+ }
715
+
716
+ GET query params (for quick testing):
717
+ /api/translate?text=Hello+world&engines=marian,libre
718
+ """
719
+ if request.method == "POST":
720
+ payload = request.get_json(silent=True) or {}
721
+ text = payload.get("text") or request.form.get("text", "")
722
+ engines = payload.get("engines") or ["marian", "libre"]
723
+ variant_filter = payload.get("variants")
724
+ else:
725
+ text = request.args.get("text", "")
726
+ engines_param = request.args.get("engines", "marian,libre")
727
+ engines = [e.strip() for e in engines_param.split(",") if e.strip()]
728
+ variants_param = request.args.get("variants")
729
+ variant_filter = [v.strip() for v in variants_param.split(",")] if variants_param else None
730
+
731
+ text = (text or "").strip()
732
+ if not text:
733
+ return jsonify({"error": "Missing 'text' parameter"}), 400
734
+
735
+ engines = [e.lower() for e in engines]
736
+ result = {"source_text": text}
737
+
738
+ if "marian" in engines:
739
+ result["marian"] = translate_marian(text)
740
+
741
+ if "libre" in engines:
742
+ variants_to_use = KABYLE_VARIANTS
743
+ if variant_filter:
744
+ variants_to_use = {
745
+ name: code for name, code in KABYLE_VARIANTS.items()
746
+ if code in variant_filter or name in variant_filter
747
+ }
748
+ if not variants_to_use:
749
+ return jsonify({"error": f"No matching variants for: {variant_filter}"}), 400
750
+
751
+ libre_results = {}
752
+ with ThreadPoolExecutor(max_workers=4) as executor:
753
+ future_to_name = {
754
+ executor.submit(translate_libre_variant, text, code): (name, code)
755
+ for name, code in variants_to_use.items()
756
+ }
757
+ for future in as_completed(future_to_name, timeout=15):
758
+ name, code = future_to_name[future]
759
+ try:
760
+ data = future.result()
761
+ except Exception as e:
762
+ data = {"success": False, "text": f"[Error: {e}]"}
763
+ data["code"] = code
764
+ libre_results[name] = data
765
+
766
+ result["libre"] = libre_results
767
+
768
+ return jsonify(result)
769
+
770
+
771
+ @app.route("/api/variants", methods=["GET"])
772
+ def api_variants():
773
+ """List available LibreTranslate Kabyle variants."""
774
+ return jsonify(KABYLE_VARIANTS)
775
+
776
+
777
  @app.route("/health")
778
  def health():
779
  return jsonify({
 
781
  "model_loaded": model is not None
782
  })
783
 
784
+
785
  if __name__ == "__main__":
786
  port = int(os.environ.get("PORT", 7860))
787
  app.run(host="0.0.0.0", port=port, debug=False)