danicor commited on
Commit
5b6287c
·
verified ·
1 Parent(s): 9938c64

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +614 -722
app.py CHANGED
@@ -1,34 +1,47 @@
 
1
  import asyncio
2
  from concurrent.futures import ThreadPoolExecutor
3
  import threading
4
- import torch
5
- from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
6
  import time
7
  import json
8
  import hashlib
9
  import re
10
  from datetime import datetime, timedelta
11
- import threading
12
  from queue import Queue
13
  import logging
14
- from typing import Dict, List, Tuple, Optional
15
  from fastapi import FastAPI, HTTPException, Request
16
  from fastapi.middleware.cors import CORSMiddleware
17
  from pydantic import BaseModel
18
  import uvicorn
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- # Enhanced logging configuration
21
  logging.basicConfig(
22
  level=logging.INFO,
23
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
24
- handlers=[
25
- logging.StreamHandler(),
26
- logging.FileHandler('translation.log')
27
- ]
28
  )
29
- logger = logging.getLogger(__name__)
30
 
31
- # Pydantic models for request/response
32
  class TranslationRequest(BaseModel):
33
  text: str
34
  source_lang: str
@@ -46,481 +59,13 @@ class TranslationResponse(BaseModel):
46
  estimated_time_remaining: Optional[float] = None
47
  current_chunk: Optional[int] = None
48
  total_chunks: Optional[int] = None
 
49
 
50
- class TranslationCache:
51
- def __init__(self, cache_duration_minutes: int = 60):
52
- self.cache = {}
53
- self.cache_duration = timedelta(minutes=cache_duration_minutes)
54
- self.lock = threading.Lock()
55
-
56
- def _generate_key(self, text: str, source_lang: str, target_lang: str) -> str:
57
- """Generate cache key from text and languages"""
58
- content = f"{text}_{source_lang}_{target_lang}"
59
- return hashlib.md5(content.encode()).hexdigest()
60
-
61
- def get(self, text: str, source_lang: str, target_lang: str) -> str:
62
- """Get translation from cache if exists and not expired"""
63
- with self.lock:
64
- key = self._generate_key(text, source_lang, target_lang)
65
- if key in self.cache:
66
- translation, timestamp = self.cache[key]
67
- if datetime.now() - timestamp < self.cache_duration:
68
- logger.info(f"[CACHE HIT] Retrieved cached translation for key: {key[:8]}... | Length: {len(translation)} chars")
69
- return translation
70
- else:
71
- # Remove expired entry
72
- del self.cache[key]
73
- logger.info(f"[CACHE EXPIRED] Removed expired cache entry for key: {key[:8]}...")
74
- logger.info(f"[CACHE MISS] No cached translation found for key: {key[:8]}...")
75
- return None
76
-
77
- def set(self, text: str, source_lang: str, target_lang: str, translation: str):
78
- """Store translation in cache"""
79
- with self.lock:
80
- key = self._generate_key(text, source_lang, target_lang)
81
- self.cache[key] = (translation, datetime.now())
82
- logger.info(f"[CACHE STORE] Cached translation for key: {key[:8]}... | Translation length: {len(translation)} chars")
83
-
84
- class TranslationQueue:
85
- def __init__(self, max_workers: int = 3):
86
- self.queue = Queue()
87
- self.max_workers = max_workers
88
- self.current_workers = 0
89
- self.lock = threading.Lock()
90
-
91
- def add_task(self, task_func, *args, **kwargs):
92
- """Add translation task to queue"""
93
- self.queue.put((task_func, args, kwargs))
94
- logger.info(f"[QUEUE] Added task to queue | Queue size: {self.queue.qsize()}")
95
-
96
- def process_queue(self):
97
- """Process tasks from queue"""
98
- while not self.queue.empty():
99
- with self.lock:
100
- if self.current_workers >= self.max_workers:
101
- time.sleep(0.1)
102
- continue
103
-
104
- if not self.queue.empty():
105
- task_func, args, kwargs = self.queue.get()
106
- self.current_workers += 1
107
- logger.info(f"[QUEUE] Starting worker | Current workers: {self.current_workers}")
108
-
109
- def worker():
110
- try:
111
- result = task_func(*args, **kwargs)
112
- return result
113
- finally:
114
- with self.lock:
115
- self.current_workers -= 1
116
- logger.info(f"[QUEUE] Worker finished | Current workers: {self.current_workers}")
117
-
118
- thread = threading.Thread(target=worker)
119
- thread.start()
120
-
121
- class TextChunker:
122
- """کلاس برای تقسیم متن طولانی به بخش‌های کوچکتر"""
123
-
124
- @staticmethod
125
- def split_text_smart(text: str, max_chunk_size: int = 400) -> List[str]:
126
- """تقسیم هوشمند متن بر اساس جملات و پاراگراف‌ها"""
127
- logger.info(f"[CHUNKER] Starting smart text splitting | Text length: {len(text)} chars | Max chunk size: {max_chunk_size}")
128
-
129
- if len(text) <= max_chunk_size:
130
- logger.info(f"[CHUNKER] Text is small, no chunking needed | Length: {len(text)}")
131
- return [text]
132
-
133
- chunks = []
134
-
135
- # تقسیم بر اساس پاراگراف‌ها
136
- paragraphs = text.split('\n\n')
137
- current_chunk = ""
138
-
139
- for i, paragraph in enumerate(paragraphs):
140
- logger.debug(f"[CHUNKER] Processing paragraph {i+1}/{len(paragraphs)} | Length: {len(paragraph)}")
141
-
142
- # اگر پاراگراف خودش بزرگ است، آن را تقسیم کن
143
- if len(paragraph) > max_chunk_size:
144
- # ذخیره قسمت فعلی اگر وجود دارد
145
- if current_chunk.strip():
146
- chunks.append(current_chunk.strip())
147
- logger.debug(f"[CHUNKER] Added chunk from accumulated paragraphs | Length: {len(current_chunk.strip())}")
148
- current_chunk = ""
149
-
150
- # تقسیم پاراگراف بزرگ
151
- sub_chunks = TextChunker._split_paragraph(paragraph, max_chunk_size)
152
- chunks.extend(sub_chunks)
153
- logger.debug(f"[CHUNKER] Split large paragraph into {len(sub_chunks)} sub-chunks")
154
- else:
155
- # بررسی اینکه آیا اضافه کردن این پاراگراف از حد تجاوز می‌کند
156
- if len(current_chunk) + len(paragraph) + 2 > max_chunk_size:
157
- if current_chunk.strip():
158
- chunks.append(current_chunk.strip())
159
- logger.debug(f"[CHUNKER] Added chunk | Length: {len(current_chunk.strip())}")
160
- current_chunk = paragraph
161
- else:
162
- if current_chunk:
163
- current_chunk += "\n\n" + paragraph
164
- else:
165
- current_chunk = paragraph
166
-
167
- # اضافه کردن آخرین قسمت
168
- if current_chunk.strip():
169
- chunks.append(current_chunk.strip())
170
- logger.debug(f"[CHUNKER] Added final chunk | Length: {len(current_chunk.strip())}")
171
-
172
- logger.info(f"[CHUNKER] Text splitting completed | Total chunks: {len(chunks)} | Average chunk size: {sum(len(c) for c in chunks) / len(chunks):.1f} chars")
173
- return chunks
174
-
175
- @staticmethod
176
- def _split_paragraph(paragraph: str, max_chunk_size: int) -> List[str]:
177
- """تقسیم پاراگراف بزرگ به جملات"""
178
- logger.debug(f"[CHUNKER] Splitting large paragraph | Length: {len(paragraph)}")
179
-
180
- # تقسیم بر اساس جملات
181
- sentences = re.split(r'[.!?]+\s+', paragraph)
182
- chunks = []
183
- current_chunk = ""
184
-
185
- for sentence in sentences:
186
- if not sentence.strip():
187
- continue
188
-
189
- # اضافه کردن علامت نقطه اگر حذف شده
190
- if not sentence.endswith(('.', '!', '?')):
191
- sentence += '.'
192
-
193
- if len(sentence) > max_chunk_size:
194
- # جمله خودش خیلی بلند است - تقسیم بر اساس کاما
195
- if current_chunk.strip():
196
- chunks.append(current_chunk.strip())
197
- current_chunk = ""
198
-
199
- sub_chunks = TextChunker._split_by_comma(sentence, max_chunk_size)
200
- chunks.extend(sub_chunks)
201
- else:
202
- if len(current_chunk) + len(sentence) + 1 > max_chunk_size:
203
- if current_chunk.strip():
204
- chunks.append(current_chunk.strip())
205
- current_chunk = sentence
206
- else:
207
- if current_chunk:
208
- current_chunk += " " + sentence
209
- else:
210
- current_chunk = sentence
211
-
212
- if current_chunk.strip():
213
- chunks.append(current_chunk.strip())
214
-
215
- logger.debug(f"[CHUNKER] Paragraph split into {len(chunks)} sentence chunks")
216
- return chunks
217
-
218
- @staticmethod
219
- def _split_by_comma(sentence: str, max_chunk_size: int) -> List[str]:
220
- """تقسیم جمله طولانی بر اساس کاما"""
221
- logger.debug(f"[CHUNKER] Splitting long sentence by comma | Length: {len(sentence)}")
222
-
223
- parts = sentence.split(', ')
224
- chunks = []
225
- current_chunk = ""
226
-
227
- for part in parts:
228
- if len(part) > max_chunk_size:
229
- # قسمت خودش خیلی بلند است - تقسیم اجباری
230
- if current_chunk.strip():
231
- chunks.append(current_chunk.strip())
232
- current_chunk = ""
233
-
234
- # تقسیم اجباری بر اساس طول
235
- while len(part) > max_chunk_size:
236
- chunks.append(part[:max_chunk_size].strip())
237
- part = part[max_chunk_size:].strip()
238
-
239
- if part:
240
- current_chunk = part
241
- else:
242
- if len(current_chunk) + len(part) + 2 > max_chunk_size:
243
- if current_chunk.strip():
244
- chunks.append(current_chunk.strip())
245
- current_chunk = part
246
- else:
247
- if current_chunk:
248
- current_chunk += ", " + part
249
- else:
250
- current_chunk = part
251
-
252
- if current_chunk.strip():
253
- chunks.append(current_chunk.strip())
254
-
255
- return chunks
256
-
257
- class MultilingualTranslator:
258
- def __init__(self, cache_duration_minutes: int = 60):
259
- self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
260
- logger.info(f"[INIT] Using device: {self.device}")
261
-
262
- # Initialize cache and queue
263
- self.cache = TranslationCache(cache_duration_minutes)
264
- self.queue = TranslationQueue()
265
-
266
- # Add thread pool for parallel processing
267
- self.executor = ThreadPoolExecutor(max_workers=3)
268
- self.background_tasks = {}
269
-
270
- logger.info(f"[INIT] Thread pool initialized with 3 workers")
271
-
272
- # Load model - using a powerful multilingual model
273
- self.model_name = "facebook/m2m100_1.2B"
274
- logger.info(f"[INIT] Loading model: {self.model_name}")
275
-
276
- try:
277
- self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
278
- self.model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name)
279
- self.model.to(self.device)
280
- logger.info(f"[INIT] Model loaded successfully on {self.device}!")
281
- except Exception as e:
282
- logger.error(f"[INIT] Error loading model: {e}")
283
- raise
284
-
285
- # تنظیمات بهینه برای ترجمه متن‌های بلند
286
- self.max_chunk_size = 350 # حداکثر طول هر قسمت
287
- self.min_chunk_overlap = 20 # همپوشانی بین قسمت‌ها
288
-
289
- # Track translation progress
290
- self.current_translation = {}
291
- self.translation_lock = threading.Lock()
292
-
293
- logger.info(f"[INIT] Translator initialized | Max chunk size: {self.max_chunk_size} chars")
294
-
295
- def translate_chunk(self, text: str, source_lang: str, target_lang: str, chunk_index: int = 0, total_chunks: int = 1) -> str:
296
- """ترجمه یک قسمت کوچک از متن"""
297
- try:
298
- logger.info(f"[TRANSLATE] Starting chunk translation [{chunk_index+1}/{total_chunks}] | {source_lang} → {target_lang} | Length: {len(text)} chars")
299
-
300
- # Set source language for tokenizer
301
- self.tokenizer.src_lang = source_lang
302
-
303
- # Encode input
304
- encoded = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=512).to(self.device)
305
- logger.debug(f"[TRANSLATE] Text encoded | Input tokens: {encoded.input_ids.shape[1]}")
306
-
307
- # Generate translation with optimized parameters
308
- start_time = time.time()
309
- generated_tokens = self.model.generate(
310
- **encoded,
311
- forced_bos_token_id=self.tokenizer.get_lang_id(target_lang),
312
- max_length=1024, # افزایش طول خروجی
313
- min_length=10, # حداقل طول خروجی
314
- num_beams=5, # افزایش تعداد beam ها برای کیفیت بهتر
315
- early_stopping=True,
316
- no_repeat_ngram_size=3, # جلوگیری از تکرار
317
- length_penalty=1.0, # تنظیم جریمه طول
318
- repetition_penalty=1.2, # جلوگیری از تکرار کلمات
319
- do_sample=False, # استفاده از روش قطعی
320
- temperature=0.7, # کنترل تنوع
321
- pad_token_id=self.tokenizer.pad_token_id,
322
- eos_token_id=self.tokenizer.eos_token_id
323
- )
324
- generation_time = time.time() - start_time
325
-
326
- # Decode result
327
- translation = self.tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)[0]
328
-
329
- # پاک‌سازی ترجمه از کاراکترهای اضافی
330
- translation = translation.strip()
331
-
332
- logger.info(f"[TRANSLATE] Chunk translation completed [{chunk_index+1}/{total_chunks}] | Generation time: {generation_time:.2f}s | Output length: {len(translation)} chars")
333
-
334
- return translation
335
-
336
- except Exception as e:
337
- logger.error(f"[TRANSLATE] Chunk translation error [{chunk_index+1}/{total_chunks}]: {e}")
338
- return f"[Translation Error: {str(e)}]"
339
-
340
- def translate_text(self, text: str, source_lang: str, target_lang: str, session_id: str = None) -> Tuple[str, float, int]:
341
- """ترجمه متن با پشتیبانی از متن‌های طولانی و لاگ‌های مفصل"""
342
- start_time = time.time()
343
-
344
- if not session_id:
345
- session_id = hashlib.md5(f"{text[:100]}{time.time()}".encode()).hexdigest()[:8]
346
-
347
- logger.info(f"[SESSION:{session_id}] Starting translation | {source_lang} → {target_lang} | Text length: {len(text)} chars")
348
-
349
- # بررسی کش برای کل متن
350
- cached_result = self.cache.get(text, source_lang, target_lang)
351
- if cached_result:
352
- logger.info(f"[SESSION:{session_id}] Translation completed from cache | Time: {time.time() - start_time:.2f}s")
353
- return cached_result, time.time() - start_time, 1
354
-
355
- try:
356
- # اگر متن کوتاه است، مستقیماً ترجمه کن
357
- if len(text) <= self.max_chunk_size:
358
- logger.info(f"[SESSION:{session_id}] Processing as short text")
359
- translation = self.translate_chunk(text, source_lang, target_lang, 0, 1)
360
-
361
- # ذخیره در کش
362
- self.cache.set(text, source_lang, target_lang, translation)
363
- processing_time = time.time() - start_time
364
- logger.info(f"[SESSION:{session_id}] Short text translation completed | Total time: {processing_time:.2f}s")
365
-
366
- return translation, processing_time, 1
367
-
368
- # تقسیم متن طولانی به قسمت‌های کوچکتر
369
- logger.info(f"[SESSION:{session_id}] Processing as long text - starting chunking")
370
- chunks = TextChunker.split_text_smart(text, self.max_chunk_size)
371
- logger.info(f"[SESSION:{session_id}] Text split into {len(chunks)} chunks")
372
-
373
- # Initialize progress tracking
374
- with self.translation_lock:
375
- self.current_translation[session_id] = {
376
- 'total_chunks': len(chunks),
377
- 'completed_chunks': 0,
378
- 'start_time': start_time,
379
- 'source_lang': source_lang,
380
- 'target_lang': target_lang
381
- }
382
-
383
- # ترجمه هر قسمت
384
- translated_chunks = []
385
- for i, chunk in enumerate(chunks):
386
- chunk_start_time = time.time()
387
- logger.info(f"[SESSION:{session_id}] Starting chunk {i+1}/{len(chunks)} | Chunk length: {len(chunk)} chars")
388
-
389
- # بررسی کش برای هر قسمت
390
- chunk_translation = self.cache.get(chunk, source_lang, target_lang)
391
-
392
- if not chunk_translation:
393
- # Estimate remaining time
394
- if i > 0:
395
- elapsed_time = time.time() - start_time
396
- avg_time_per_chunk = elapsed_time / i
397
- estimated_remaining = avg_time_per_chunk * (len(chunks) - i)
398
- logger.info(f"[SESSION:{session_id}] Progress: {i}/{len(chunks)} | Avg time per chunk: {avg_time_per_chunk:.1f}s | Estimated remaining: {estimated_remaining:.1f}s")
399
-
400
- chunk_translation = self.translate_chunk(chunk, source_lang, target_lang, i, len(chunks))
401
- # ذخیره قسمت در کش
402
- self.cache.set(chunk, source_lang, target_lang, chunk_translation)
403
-
404
- chunk_time = time.time() - chunk_start_time
405
- logger.info(f"[SESSION:{session_id}] Chunk {i+1}/{len(chunks)} translated in {chunk_time:.2f}s")
406
- else:
407
- logger.info(f"[SESSION:{session_id}] Chunk {i+1}/{len(chunks)} retrieved from cache")
408
-
409
- translated_chunks.append(chunk_translation)
410
-
411
- # Update progress
412
- with self.translation_lock:
413
- if session_id in self.current_translation:
414
- self.current_translation[session_id]['completed_chunks'] = i + 1
415
-
416
- # کمی استراحت بین ترجمه‌ها برای جلوگیری از بارگذاری زیاد
417
- if i < len(chunks) - 1:
418
- time.sleep(0.1)
419
-
420
- # ترکیب قسمت‌های ترجمه شده
421
- logger.info(f"[SESSION:{session_id}] Combining translated chunks")
422
- final_translation = self._combine_translations(translated_chunks, text)
423
-
424
- # ذخیره نتیجه نهایی در کش
425
- self.cache.set(text, source_lang, target_lang, final_translation)
426
-
427
- processing_time = time.time() - start_time
428
- logger.info(f"[SESSION:{session_id}] Long text translation completed | Total time: {processing_time:.2f}s | Chunks: {len(chunks)} | Final length: {len(final_translation)} chars")
429
-
430
- # Clean up progress tracking
431
- with self.translation_lock:
432
- self.current_translation.pop(session_id, None)
433
-
434
- return final_translation, processing_time, len(chunks)
435
-
436
- except Exception as e:
437
- logger.error(f"[SESSION:{session_id}] Translation error: {e}")
438
- # Clean up progress tracking
439
- with self.translation_lock:
440
- self.current_translation.pop(session_id, None)
441
- return f"Translation error: {str(e)}", time.time() - start_time, 0
442
-
443
- def get_translation_progress(self, session_id: str) -> Dict:
444
- """Get current translation progress"""
445
- with self.translation_lock:
446
- if session_id not in self.current_translation:
447
- return None
448
-
449
- progress = self.current_translation[session_id].copy()
450
- elapsed_time = time.time() - progress['start_time']
451
-
452
- if progress['completed_chunks'] > 0:
453
- avg_time_per_chunk = elapsed_time / progress['completed_chunks']
454
- remaining_chunks = progress['total_chunks'] - progress['completed_chunks']
455
- estimated_remaining = avg_time_per_chunk * remaining_chunks
456
- else:
457
- estimated_remaining = None
458
-
459
- return {
460
- 'total_chunks': progress['total_chunks'],
461
- 'completed_chunks': progress['completed_chunks'],
462
- 'elapsed_time': elapsed_time,
463
- 'estimated_remaining': estimated_remaining,
464
- 'progress_percentage': (progress['completed_chunks'] / progress['total_chunks']) * 100
465
- }
466
-
467
- def _combine_translations(self, translated_chunks: List[str], original_text: str) -> str:
468
- """ترکیب قسمت‌های ترج��ه شده به یک متن یکپارچه"""
469
- if not translated_chunks:
470
- return ""
471
-
472
- if len(translated_chunks) == 1:
473
- return translated_chunks[0]
474
-
475
- logger.debug(f"[COMBINER] Combining {len(translated_chunks)} translated chunks")
476
-
477
- # ترکیب قسمت‌ها با در نظر گیری ساختار اصلی متن
478
- combined = []
479
-
480
- for i, chunk in enumerate(translated_chunks):
481
- # پاک‌سازی قسمت
482
- chunk = chunk.strip()
483
-
484
- if not chunk:
485
- continue
486
-
487
- # اضافه کردن فاصله مناسب بین قسمت‌ها
488
- if i > 0 and combined:
489
- # اگر قسمت قبلی با نقطه تمام نمی‌شود، نقطه اضافه کن
490
- if not combined[-1].rstrip().endswith(('.', '!', '?', ':', 'Ø›', '.')):
491
- combined[-1] += '.'
492
-
493
- # بررسی اینکه آیا نیاز به پاراگراف جدید داریم
494
- if '\n\n' in original_text:
495
- combined.append('\n\n' + chunk)
496
- else:
497
- combined.append(' ' + chunk)
498
- else:
499
- combined.append(chunk)
500
-
501
- result = ''.join(combined)
502
-
503
- # پاک‌سازی نهایی
504
- result = re.sub(r'\s+', ' ', result) # حذف فاصله‌های اضافی
505
- result = re.sub(r'\.+', '.', result) # حذف نقطه‌های تکراری
506
- result = result.strip()
507
-
508
- logger.debug(f"[COMBINER] Combined translation length: {len(result)} chars")
509
- return result
510
-
511
- async def translate_text_async(self, text: str, source_lang: str, target_lang: str, session_id: str = None):
512
- """Async wrapper for translate_text"""
513
- loop = asyncio.get_event_loop()
514
- return await loop.run_in_executor(
515
- self.executor,
516
- self.translate_text,
517
- text, source_lang, target_lang, session_id
518
- )
519
-
520
- # Language mappings for M2M100 model
521
  LANGUAGE_MAP = {
522
  "English": "en",
523
- "Persian (Farsi)": "fa",
 
524
  "Arabic": "ar",
525
  "French": "fr",
526
  "German": "de",
@@ -529,6 +74,7 @@ LANGUAGE_MAP = {
529
  "Portuguese": "pt",
530
  "Russian": "ru",
531
  "Chinese (Simplified)": "zh",
 
532
  "Japanese": "ja",
533
  "Korean": "ko",
534
  "Hindi": "hi",
@@ -588,156 +134,555 @@ LANGUAGE_MAP = {
588
  "Zulu": "zu"
589
  }
590
 
591
- # Initialize translator
592
- translator = MultilingualTranslator(60)
 
593
 
594
- # Create FastAPI app
595
- app = FastAPI(title="Enhanced Multilingual Translation API", version="2.1.0")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
 
597
- # Add CORS middleware
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  app.add_middleware(
599
  CORSMiddleware,
600
- allow_origins=["*"],
601
  allow_credentials=True,
602
  allow_methods=["*"],
603
  allow_headers=["*"],
604
  )
605
 
 
606
  @app.get("/")
607
  async def root():
608
  return {
609
- "message": "Enhanced Multilingual Translation API v2.1",
610
- "status": "active",
611
- "features": [
612
- "enhanced_logging",
613
- "progress_tracking",
614
- "long_text_support",
615
- "smart_chunking",
616
- "cache_optimization"
617
- ]
618
  }
619
 
620
- @app.post("/api/translate")
621
  async def api_translate(request: TranslationRequest):
622
- """API endpoint for translation with enhanced logging and progress tracking"""
623
- if not request.text.strip():
 
 
 
 
624
  raise HTTPException(status_code=400, detail="No text provided")
625
-
626
- source_code = LANGUAGE_MAP.get(request.source_lang)
627
- target_code = LANGUAGE_MAP.get(request.target_lang)
628
-
629
- if not source_code or not target_code:
630
- raise HTTPException(status_code=400, detail="Invalid language codes")
631
-
632
  try:
633
- # Generate session ID for tracking
634
- session_id = hashlib.md5(f"{request.text[:100]}{time.time()}".encode()).hexdigest()[:8]
635
-
636
- translation, processing_time, chunks_count = translator.translate_text(
637
- request.text, source_code, target_code, session_id
638
- )
639
-
640
  return TranslationResponse(
641
  translation=translation,
642
  source_language=request.source_lang,
643
  target_language=request.target_lang,
644
- processing_time=processing_time,
645
- character_count=len(request.text),
646
  status="success",
647
- chunks_processed=chunks_count
 
 
 
 
648
  )
649
  except Exception as e:
650
- logger.error(f"[API] Translation error: {str(e)}")
651
- raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}")
652
 
653
- # Alternative endpoint for form data (compatibility with WordPress)
654
  @app.post("/api/translate/form")
655
  async def api_translate_form(request: Request):
656
- """Non-blocking translation endpoint with enhanced error handling"""
 
 
 
 
 
 
657
  try:
658
- form_data = await request.form()
659
- text = form_data.get("text", "")
660
- source_lang = form_data.get("source_lang", "")
661
- target_lang = form_data.get("target_lang", "")
662
- api_key = form_data.get("api_key", None)
663
- except:
 
 
 
 
664
  try:
665
- json_data = await request.json()
666
- text = json_data.get("text", "")
667
- source_lang = json_data.get("source_lang", "")
668
- target_lang = json_data.get("target_lang", "")
669
- api_key = json_data.get("api_key", None)
670
- except:
671
  raise HTTPException(status_code=400, detail="Invalid request format")
672
-
673
- logger.info(f"[FORM API] Translation request | {source_lang} → {target_lang} | Length: {len(text)} chars")
674
-
 
 
 
 
675
  if not text.strip():
676
- logger.error("[FORM API] No text provided")
677
  return {"status": "error", "message": "No text provided"}
678
-
679
- source_code = LANGUAGE_MAP.get(source_lang)
680
- target_code = LANGUAGE_MAP.get(target_lang)
681
-
682
- if not source_code or not target_code:
683
- logger.error(f"[FORM API] Invalid language codes: {source_lang} -> {target_lang}")
684
- return {"status": "error", "message": "Invalid language codes"}
685
-
686
- # Generate session ID for tracking
687
- session_id = hashlib.md5(f"{text[:100]}{time.time()}".encode()).hexdigest()[:8]
688
-
689
- # Check if it's a long text that should be processed in background
690
  if len(text) > translator.max_chunk_size:
691
- # 🔹 اول بررسی کن آیا نتیجه در کش وجود دارد یا نه
692
- cached_result = translator.cache.get(text, source_code, target_code)
693
- if cached_result:
694
- logger.info(f"[FORM API] Returning cached translation immediately for session: {session_id}")
695
  return {
696
- "translation": cached_result,
697
- "source_language": source_lang,
698
- "target_language": target_lang,
699
  "processing_time": 0.0,
700
  "character_count": len(text),
701
  "status": "success",
702
  "chunks_processed": None,
703
  "session_id": session_id,
704
- "is_heavy_text": False,
705
  "cached": True
706
  }
707
- # 🔹 اگر در کش نبود → پس بفرست به background
708
- task = asyncio.create_task(
709
- translator.translate_text_async(text, source_code, target_code, session_id)
710
- )
 
711
  translator.background_tasks[session_id] = task
712
-
713
- logger.info(f"[FORM API] Started background translation for session: {session_id}")
714
-
715
  return {
716
  "session_id": session_id,
717
  "request_id": session_id,
718
  "status": "processing",
719
- "message": "Translation started in background. Use CHECK RESULT to get your translation.",
720
  "character_count": len(text),
721
  "is_background": True,
722
  "is_heavy_text": True
723
  }
724
  else:
725
- # Process short text immediately
726
  try:
727
- translation, processing_time, chunks_count = await translator.translate_text_async(
728
- text, source_code, target_code, session_id
729
- )
730
-
731
- # بررسی محتوای ترجمه
732
- if not translation or not translation.strip() or translation.startswith("Translation error"):
733
- logger.error(f"[FORM API] Invalid translation result: {translation[:100] if translation else 'None'}")
734
- return {
735
- "status": "error",
736
- "message": "Translation failed - empty or invalid result",
737
- "session_id": session_id
738
- }
739
-
740
- logger.info(f"[FORM API] Translation successful | Length: {len(translation)} chars")
741
  return {
742
  "translation": translation,
743
  "source_language": source_lang,
@@ -746,172 +691,119 @@ async def api_translate_form(request: Request):
746
  "character_count": len(text),
747
  "status": "success",
748
  "chunks_processed": chunks_count,
749
- "session_id": session_id
 
750
  }
751
  except Exception as e:
752
- logger.error(f"[FORM API] Translation error: {str(e)}")
753
  return {"status": "error", "message": f"Translation error: {str(e)}"}
754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
  @app.get("/api/progress/{session_id}")
756
  async def get_translation_progress(session_id: str):
757
- """Get translation progress for a session"""
758
- progress = translator.get_translation_progress(session_id)
759
- if progress is None:
760
  raise HTTPException(status_code=404, detail="Session not found or completed")
761
-
762
- return {
763
- "status": "success",
764
- "progress": progress
765
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
 
767
  @app.get("/api/languages")
768
  async def get_languages():
769
- """Get supported languages"""
770
- return {
771
- "languages": list(LANGUAGE_MAP.keys()),
772
- "language_codes": LANGUAGE_MAP,
773
- "status": "success"
774
- }
775
 
776
  @app.get("/api/health")
777
  async def health_check():
778
- """Health check endpoint"""
779
  return {
780
  "status": "healthy",
781
- "device": str(translator.device),
782
- "model": translator.model_name,
783
  "cache_size": len(translator.cache.cache),
784
  "max_chunk_size": translator.max_chunk_size,
785
  "active_translations": len(translator.current_translation),
 
786
  "version": "2.1.0"
787
  }
788
 
789
- @app.get("/api/status/{session_id}")
790
- async def get_session_status(session_id: str):
791
- """Get translation status - non-blocking"""
792
-
793
- # Check if task is in background tasks
794
- if session_id in translator.background_tasks:
795
- task = translator.background_tasks[session_id]
796
-
797
- if task.done():
798
- try:
799
- translation, processing_time, chunks_count = await task
800
- # Clean up completed task
801
- del translator.background_tasks[session_id]
802
-
803
- return {
804
- "status": "completed",
805
- "translation": translation,
806
- "processing_time": processing_time,
807
- "chunks_processed": chunks_count,
808
- "message": "Translation completed successfully"
809
- }
810
- except Exception as e:
811
- del translator.background_tasks[session_id]
812
- return {
813
- "status": "failed",
814
- "message": f"Translation failed: {str(e)}"
815
- }
816
- else:
817
- # Task still running - get progress
818
- progress = translator.get_translation_progress(session_id)
819
-
820
- if progress:
821
- return {
822
- "status": "processing",
823
- "progress": progress,
824
- "message": f"Processing chunk {progress['completed_chunks']}/{progress['total_chunks']}",
825
- "estimated_remaining": progress.get('estimated_remaining', 0)
826
- }
827
- else:
828
- return {
829
- "status": "processing",
830
- "message": "Translation in progress...",
831
- "progress": None
832
- }
833
-
834
- # Check current active translations
835
- progress = translator.get_translation_progress(session_id)
836
- if progress:
837
- return {
838
- "status": "processing",
839
- "progress": progress,
840
- "message": f"Processing chunk {progress['completed_chunks']}/{progress['total_chunks']}",
841
- "estimated_remaining": progress.get('estimated_remaining', 0)
842
- }
843
-
844
- return {
845
- "status": "not_found",
846
- "message": "Session not found or completed"
847
- }
848
-
849
  @app.get("/api/server-status")
850
  async def get_server_status():
851
- """Get current server status - non-blocking"""
852
  active_sessions = []
853
- background_tasks_count = len(translator.background_tasks)
854
-
855
  with translator.translation_lock:
856
- for session_id, progress in translator.current_translation.items():
857
- elapsed_time = time.time() - progress['start_time']
858
- if progress['completed_chunks'] > 0:
859
- avg_time_per_chunk = elapsed_time / progress['completed_chunks']
860
- remaining_chunks = progress['total_chunks'] - progress['completed_chunks']
861
- estimated_remaining = avg_time_per_chunk * remaining_chunks
862
- else:
863
- estimated_remaining = None
864
-
865
  active_sessions.append({
866
- 'session_id': session_id,
867
- 'source_lang': progress['source_lang'],
868
- 'target_lang': progress['target_lang'],
869
- 'total_chunks': progress['total_chunks'],
870
- 'completed_chunks': progress['completed_chunks'],
871
- 'progress_percentage': (progress['completed_chunks'] / progress['total_chunks']) * 100,
872
- 'elapsed_time': elapsed_time,
873
- 'estimated_remaining': estimated_remaining
874
  })
875
-
876
- if active_sessions or background_tasks_count > 0:
877
- if active_sessions:
878
- latest_session = active_sessions[-1]
879
- message = f"Processing chunk {latest_session['completed_chunks']}/{latest_session['total_chunks']} | {latest_session['source_lang']} → {latest_session['target_lang']}"
880
- else:
881
- message = f"{background_tasks_count} translation(s) in background queue"
882
-
883
- return {
884
- "has_active_translation": True,
885
- "status": "processing",
886
- "message": message,
887
- "active_sessions": len(active_sessions),
888
- "background_tasks": background_tasks_count,
889
- "total_active": len(active_sessions) + background_tasks_count
890
- }
891
- else:
892
- return {
893
- "has_active_translation": False,
894
- "status": "idle",
895
- "message": "Server is ready for new translations",
896
- "active_sessions": 0,
897
- "background_tasks": 0
898
- }
899
-
900
- if active_sessions:
901
- # Return the most recent active session
902
- latest_session = active_sessions[-1]
903
- return {
904
- "has_active_translation": True,
905
- "status": "processing",
906
- "message": f"Processing chunk {latest_session['completed_chunks']}/{latest_session['total_chunks']} | {latest_session['source_lang']} → {latest_session['target_lang']}",
907
- "session_data": latest_session
908
- }
909
- else:
910
- return {
911
- "has_active_translation": False,
912
- "status": "no_active_translation",
913
- "message": "No active translation on server"
914
- }
915
 
 
916
  if __name__ == "__main__":
917
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
 
 
1
+ # server.py
2
  import asyncio
3
  from concurrent.futures import ThreadPoolExecutor
4
  import threading
 
 
5
  import time
6
  import json
7
  import hashlib
8
  import re
9
  from datetime import datetime, timedelta
 
10
  from queue import Queue
11
  import logging
12
+ from typing import Dict, List, Tuple, Optional, Any
13
  from fastapi import FastAPI, HTTPException, Request
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from pydantic import BaseModel
16
  import uvicorn
17
+ import os
18
+
19
+ # Optional: Transformers (if you want local model)
20
+ # If you don't plan to run a local transformer, you can still keep API and adapt.
21
+ try:
22
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
23
+ TRANSFORMERS_AVAILABLE = True
24
+ except Exception:
25
+ TRANSFORMERS_AVAILABLE = False
26
+
27
+ # ----------------------- Configuration -----------------------
28
+ LOG_FILE = os.environ.get("TRANSLATION_LOG", "translation.log")
29
+ HF_MODEL = os.environ.get("HF_MODEL", "facebook/m2m100_418M") # change to 1.2B if you have resources
30
+ MAX_WORKERS = int(os.environ.get("MAX_WORKERS", "3"))
31
+ CACHE_MINUTES = int(os.environ.get("CACHE_MINUTES", "60"))
32
+ MAX_CHUNK_SIZE = int(os.environ.get("MAX_CHUNK_SIZE", "350"))
33
+ SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
34
+ SERVER_PORT = int(os.environ.get("SERVER_PORT", "7860"))
35
 
36
+ # ----------------------- Logging -----------------------
37
  logging.basicConfig(
38
  level=logging.INFO,
39
+ format="%(asctime)s - %(levelname)s - %(message)s",
40
+ handlers=[logging.StreamHandler(), logging.FileHandler(LOG_FILE)]
 
 
 
41
  )
42
+ logger = logging.getLogger("translator-server")
43
 
44
+ # ----------------------- Pydantic Models -----------------------
45
  class TranslationRequest(BaseModel):
46
  text: str
47
  source_lang: str
 
59
  estimated_time_remaining: Optional[float] = None
60
  current_chunk: Optional[int] = None
61
  total_chunks: Optional[int] = None
62
+ session_id: Optional[str] = None
63
 
64
+ # ----------------------- Language Map -----------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  LANGUAGE_MAP = {
66
  "English": "en",
67
+ "Persian (Farsi)": "fa",
68
+ "Persian": "fa",
69
  "Arabic": "ar",
70
  "French": "fr",
71
  "German": "de",
 
74
  "Portuguese": "pt",
75
  "Russian": "ru",
76
  "Chinese (Simplified)": "zh",
77
+ "Chinese": "zh",
78
  "Japanese": "ja",
79
  "Korean": "ko",
80
  "Hindi": "hi",
 
134
  "Zulu": "zu"
135
  }
136
 
137
+ # ----------------------- Helpers -----------------------
138
+ def generate_session_id(prefix: str = "") -> str:
139
+ return hashlib.md5(f"{prefix}_{time.time()}_{os.urandom(8)}".encode()).hexdigest()[:12]
140
 
141
+ # ----------------------- Cache -----------------------
142
+ class TranslationCache:
143
+ def __init__(self, cache_duration_minutes: int = CACHE_MINUTES):
144
+ self.cache: Dict[str, Tuple[str, datetime]] = {}
145
+ self.cache_duration = timedelta(minutes=cache_duration_minutes)
146
+ self.lock = threading.Lock()
147
+
148
+ def _generate_key(self, text: str, source_lang: str, target_lang: str) -> str:
149
+ content = f"{text}__{source_lang}__{target_lang}"
150
+ return hashlib.sha256(content.encode()).hexdigest()
151
+
152
+ def get(self, text: str, source_lang: str, target_lang: str) -> Optional[str]:
153
+ with self.lock:
154
+ key = self._generate_key(text, source_lang, target_lang)
155
+ entry = self.cache.get(key)
156
+ if entry:
157
+ translation, ts = entry
158
+ if datetime.utcnow() - ts < self.cache_duration:
159
+ logger.info(f"[CACHE HIT] {key[:8]} len={len(translation)}")
160
+ return translation
161
+ else:
162
+ del self.cache[key]
163
+ logger.info(f"[CACHE EXPIRED] {key[:8]}")
164
+ logger.debug(f"[CACHE MISS] {key[:8]}")
165
+ return None
166
+
167
+ def set(self, text: str, source_lang: str, target_lang: str, translation: str):
168
+ with self.lock:
169
+ key = self._generate_key(text, source_lang, target_lang)
170
+ self.cache[key] = (translation, datetime.utcnow())
171
+ logger.info(f"[CACHE SET] {key[:8]} len={len(translation)}")
172
+
173
+ # ----------------------- Smart Chunker -----------------------
174
+ class TextChunker:
175
+ """Smart splitting: paragraphs -> sentences -> commas fallback."""
176
+
177
+ @staticmethod
178
+ def split_text_smart(text: str, max_chunk_size: int = MAX_CHUNK_SIZE) -> List[str]:
179
+ text = text.strip()
180
+ if not text:
181
+ return []
182
+ if len(text) <= max_chunk_size:
183
+ return [text]
184
+
185
+ # First split by paragraphs to preserve structure
186
+ paragraphs = [p.strip() for p in re.split(r'\n{2,}', text) if p.strip()]
187
+ chunks: List[str] = []
188
+ current = ""
189
+
190
+ for p in paragraphs:
191
+ if len(p) <= max_chunk_size:
192
+ if not current:
193
+ current = p
194
+ else:
195
+ if len(current) + 2 + len(p) <= max_chunk_size:
196
+ current += "\n\n" + p
197
+ else:
198
+ chunks.append(current.strip())
199
+ current = p
200
+ else:
201
+ # paragraph too large -> split to sentences
202
+ if current:
203
+ chunks.append(current.strip())
204
+ current = ""
205
+ parts = TextChunker._split_paragraph(p, max_chunk_size)
206
+ chunks.extend(parts)
207
+
208
+ if current:
209
+ chunks.append(current.strip())
210
+
211
+ # Safety: merge very small chunks
212
+ merged: List[str] = []
213
+ for c in chunks:
214
+ if not merged:
215
+ merged.append(c)
216
+ else:
217
+ if len(merged[-1]) + 1 + len(c) <= max_chunk_size:
218
+ merged[-1] = merged[-1] + "\n\n" + c
219
+ else:
220
+ merged.append(c)
221
+
222
+ logger.info(f"[CHUNKER] split into {len(merged)} chunks (avg {sum(len(x) for x in merged)/len(merged):.1f} chars)")
223
+ return merged
224
+
225
+ @staticmethod
226
+ def _split_paragraph(paragraph: str, max_chunk_size: int) -> List[str]:
227
+ sentences = re.split(r'(?<=[.!?])\s+', paragraph)
228
+ chunks: List[str] = []
229
+ current = ""
230
+ for s in sentences:
231
+ s = s.strip()
232
+ if not s:
233
+ continue
234
+ if len(s) > max_chunk_size:
235
+ # fallback: split by commas
236
+ parts = TextChunker._split_by_comma(s, max_chunk_size)
237
+ if current:
238
+ chunks.append(current.strip()); current = ""
239
+ chunks.extend(parts)
240
+ else:
241
+ if not current:
242
+ current = s
243
+ elif len(current) + 1 + len(s) <= max_chunk_size:
244
+ current += " " + s
245
+ else:
246
+ chunks.append(current.strip())
247
+ current = s
248
+ if current:
249
+ chunks.append(current.strip())
250
+ return chunks
251
+
252
+ @staticmethod
253
+ def _split_by_comma(sentence: str, max_chunk_size: int) -> List[str]:
254
+ parts = [p.strip() for p in sentence.split(',') if p.strip()]
255
+ chunks: List[str] = []
256
+ current = ""
257
+ for p in parts:
258
+ if len(p) > max_chunk_size:
259
+ # hard cut
260
+ i = 0
261
+ while i < len(p):
262
+ slice_ = p[i:i+max_chunk_size].strip()
263
+ if slice_:
264
+ chunks.append(slice_)
265
+ i += max_chunk_size
266
+ else:
267
+ if not current:
268
+ current = p
269
+ elif len(current) + 2 + len(p) <= max_chunk_size:
270
+ current += ", " + p
271
+ else:
272
+ chunks.append(current.strip())
273
+ current = p
274
+ if current:
275
+ chunks.append(current.strip())
276
+ return chunks
277
+
278
+ # ----------------------- Translator Core -----------------------
279
+ class MultilingualTranslator:
280
+ def __init__(self, cache_minutes: int = CACHE_MINUTES, max_workers: int = MAX_WORKERS):
281
+ self.device = "cpu"
282
+ self.model_name = HF_MODEL
283
+ self.tokenizer = None
284
+ self.model = None
285
+ self.generation_lock = threading.Lock() # ensure model.generate serialized
286
+ self.executor = ThreadPoolExecutor(max_workers=max_workers)
287
+ self.background_tasks: Dict[str, asyncio.Task] = {}
288
+ self.cache = TranslationCache(cache_minutes)
289
+ self.current_translation: Dict[str, Dict[str, Any]] = {}
290
+ self.translation_lock = threading.Lock()
291
+ self.max_chunk_size = MAX_CHUNK_SIZE
292
+
293
+ if TRANSFORMERS_AVAILABLE:
294
+ try:
295
+ # prefer GPU if available
296
+ import torch as _torch
297
+ self.device = "cuda" if _torch.cuda.is_available() else "cpu"
298
+ logger.info(f"[MODEL] Loading {self.model_name} on {self.device} (this may take time)...")
299
+ self.tokenizer = AutoTokenizer.from_pretrained(self.model_name, use_fast=False)
300
+ self.model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name)
301
+ if self.device == "cuda":
302
+ self.model.to("cuda")
303
+ logger.info("[MODEL] Model loaded successfully.")
304
+ except Exception as e:
305
+ logger.exception(f"[MODEL] Failed to load model '{self.model_name}': {e}")
306
+ self.model = None
307
+ self.tokenizer = None
308
+ else:
309
+ logger.warning("[MODEL] transformers not available — running in mock mode (no local model).")
310
+
311
+ # internal chunk translation executed in threadpool (but generation uses generation_lock)
312
+ def _translate_chunk_sync(self, text: str, src_code: str, tgt_code: str, chunk_index: int = 0, total_chunks: int = 1) -> str:
313
+ """Synchronous chunk translation (called in executor)."""
314
+ if not text:
315
+ return ""
316
+ if self.model is None or self.tokenizer is None:
317
+ # mock: prefix target language code if no model
318
+ logger.warning("[TRANSLATE] No model available, returning mock translation.")
319
+ return f"[{tgt_code}] {text}"
320
+
321
+ try:
322
+ # set tokenizer language if model supports
323
+ with self.generation_lock:
324
+ # some tokenizers use .src_lang (M2M100)
325
+ try:
326
+ if hasattr(self.tokenizer, "src_lang"):
327
+ self.tokenizer.src_lang = src_code
328
+ except Exception:
329
+ pass
330
+
331
+ inputs = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
332
+ if hasattr(inputs, "to"):
333
+ # pyright typing
334
+ pass
335
+ # Move tensors to device if cuda
336
+ import torch as _torch
337
+ if self.device == "cuda":
338
+ for k, v in inputs.items():
339
+ if isinstance(v, _torch.Tensor):
340
+ inputs[k] = v.to("cuda")
341
+
342
+ # determine forced_bos_token_id if available
343
+ forced_bos = None
344
+ try:
345
+ if hasattr(self.tokenizer, "get_lang_id"):
346
+ forced_bos = self.tokenizer.get_lang_id(tgt_code)
347
+ except Exception:
348
+ forced_bos = None
349
+
350
+ gen_kwargs = dict(
351
+ **inputs,
352
+ max_length=1024,
353
+ num_beams=4,
354
+ early_stopping=True
355
+ )
356
+ if forced_bos is not None:
357
+ gen_kwargs["forced_bos_token_id"] = forced_bos
358
+
359
+ t0 = time.time()
360
+ outputs = self.model.generate(**gen_kwargs)
361
+ gen_time = time.time() - t0
362
+
363
+ # move to cpu if needed for decode
364
+ decoded = self.tokenizer.batch_decode(outputs, skip_special_tokens=True)[0].strip()
365
+ logger.info(f"[GEN] chunk {chunk_index+1}/{total_chunks} generated in {gen_time:.2f}s len={len(decoded)}")
366
+ return decoded
367
+ except Exception as e:
368
+ logger.exception(f"[TRANSLATE] Error generating chunk: {e}")
369
+ return f"[Translation Error: {str(e)}]"
370
 
371
+ async def translate_chunk_async(self, text: str, src_code: str, tgt_code: str, chunk_index: int = 0, total_chunks: int = 1) -> str:
372
+ loop = asyncio.get_event_loop()
373
+ return await loop.run_in_executor(self.executor, self._translate_chunk_sync, text, src_code, tgt_code, chunk_index, total_chunks)
374
+
375
+ async def translate_text_async(self, text: str, src_code: str, tgt_code: str, session_id: Optional[str] = None) -> Tuple[str, float, int]:
376
+ """Full translation (async wrapper). Returns (translation, processing_time, chunks_count)"""
377
+ start_time = time.time()
378
+ if not session_id:
379
+ session_id = generate_session_id("sess")
380
+
381
+ # check cache full text
382
+ cached_full = self.cache.get(text, src_code, tgt_code)
383
+ if cached_full:
384
+ return cached_full, time.time() - start_time, 1
385
+
386
+ # short text
387
+ if len(text) <= self.max_chunk_size:
388
+ # update progress
389
+ with self.translation_lock:
390
+ self.current_translation[session_id] = {
391
+ "total_chunks": 1,
392
+ "completed_chunks": 0,
393
+ "start_time": start_time,
394
+ "source_lang": src_code,
395
+ "target_lang": tgt_code
396
+ }
397
+ chunk_result = await self.translate_chunk_async(text, src_code, tgt_code, 0, 1)
398
+ self.cache.set(text, src_code, tgt_code, chunk_result)
399
+ elapsed = time.time() - start_time
400
+ with self.translation_lock:
401
+ self.current_translation.pop(session_id, None)
402
+ return chunk_result, elapsed, 1
403
+
404
+ # long text -> chunking
405
+ chunks = TextChunker.split_text_smart(text, self.max_chunk_size)
406
+ total = len(chunks)
407
+ translated_chunks: List[str] = []
408
+
409
+ with self.translation_lock:
410
+ self.current_translation[session_id] = {
411
+ "total_chunks": total,
412
+ "completed_chunks": 0,
413
+ "start_time": start_time,
414
+ "source_lang": src_code,
415
+ "target_lang": tgt_code
416
+ }
417
+
418
+ for i, chunk in enumerate(chunks):
419
+ # check cached per-chunk
420
+ c_cached = self.cache.get(chunk, src_code, tgt_code)
421
+ if c_cached:
422
+ translated_chunks.append(c_cached)
423
+ with self.translation_lock:
424
+ self.current_translation[session_id]["completed_chunks"] = i + 1
425
+ logger.debug(f"[SESSION {session_id}] chunk {i+1}/{total} from cache")
426
+ continue
427
+
428
+ # translate chunk
429
+ chunk_translation = await self.translate_chunk_async(chunk, src_code, tgt_code, i, total)
430
+ translated_chunks.append(chunk_translation)
431
+ self.cache.set(chunk, src_code, tgt_code, chunk_translation)
432
+ with self.translation_lock:
433
+ self.current_translation[session_id]["completed_chunks"] = i + 1
434
+
435
+ # short pause to yield CPU
436
+ await asyncio.sleep(0.01)
437
+
438
+ # combine
439
+ final = self._combine_translations(translated_chunks, text)
440
+
441
+ # set full-text cache
442
+ self.cache.set(text, src_code, tgt_code, final)
443
+
444
+ elapsed = time.time() - start_time
445
+ with self.translation_lock:
446
+ self.current_translation.pop(session_id, None)
447
+
448
+ return final, elapsed, total
449
+
450
+ def submit_background(self, text: str, src_code: str, tgt_code: str, session_id: Optional[str] = None) -> str:
451
+ """Schedule background translation and return session_id immediately"""
452
+ if not session_id:
453
+ session_id = generate_session_id("bg")
454
+ loop = asyncio.get_event_loop()
455
+ task = loop.create_task(self._bg_task_wrapper(text, src_code, tgt_code, session_id))
456
+ self.background_tasks[session_id] = task
457
+ logger.info(f"[BG] Scheduled background task {session_id}")
458
+ return session_id
459
+
460
+ async def _bg_task_wrapper(self, text: str, src_code: str, tgt_code: str, session_id: str):
461
+ """Wrapper executed in background to run translate_text_async and keep result accessible"""
462
+ try:
463
+ result, elapsed, chunks = await self.translate_text_async(text, src_code, tgt_code, session_id)
464
+ # store result for retrieval
465
+ with self.translation_lock:
466
+ # we can store result in background_tasks as result property or a separate dict
467
+ # here, we'll attach attributes to task for simplicity
468
+ task = self.background_tasks.get(session_id)
469
+ if task is not None:
470
+ # monkeypatch result
471
+ setattr(task, "result_data", {
472
+ "translation": result,
473
+ "processing_time": elapsed,
474
+ "chunks": chunks,
475
+ "character_count": len(text),
476
+ "status": "completed"
477
+ })
478
+ logger.info(f"[BG] Completed background {session_id} len={len(result)}")
479
+ except Exception as e:
480
+ logger.exception(f"[BG] Error in background task {session_id}: {e}")
481
+ task = self.background_tasks.get(session_id)
482
+ if task is not None:
483
+ setattr(task, "result_data", {
484
+ "translation": None,
485
+ "processing_time": 0.0,
486
+ "chunks": 0,
487
+ "character_count": len(text),
488
+ "status": "failed",
489
+ "error": str(e)
490
+ })
491
+
492
+ def get_background_result(self, session_id: str) -> Optional[Dict]:
493
+ task = self.background_tasks.get(session_id)
494
+ if not task:
495
+ return None
496
+ if task.done():
497
+ # if result_data present, return it
498
+ res = getattr(task, "result_data", None)
499
+ # cleanup
500
+ try:
501
+ del self.background_tasks[session_id]
502
+ except KeyError:
503
+ pass
504
+ return res
505
+ else:
506
+ return {
507
+ "status": "processing",
508
+ "progress": self.get_translation_progress(session_id)
509
+ }
510
+
511
+ def get_translation_progress(self, session_id: str) -> Optional[Dict]:
512
+ with self.translation_lock:
513
+ if session_id not in self.current_translation:
514
+ return None
515
+ p = self.current_translation[session_id].copy()
516
+ elapsed = time.time() - p['start_time']
517
+ completed = p.get('completed_chunks', 0)
518
+ total = p.get('total_chunks', 1)
519
+ estimated_remaining = None
520
+ if completed > 0:
521
+ avg = elapsed / completed
522
+ estimated_remaining = avg * (total - completed)
523
+ return {
524
+ "total_chunks": total,
525
+ "completed_chunks": completed,
526
+ "elapsed_time": elapsed,
527
+ "estimated_remaining": estimated_remaining,
528
+ "progress_percentage": (completed / total) * 100 if total else 0
529
+ }
530
+
531
+ def _combine_translations(self, translated_chunks: List[str], original_text: str) -> str:
532
+ # simple join preserving paragraph breaks if existed
533
+ if not translated_chunks:
534
+ return ""
535
+ # if original had paragraphs
536
+ if "\n\n" in original_text:
537
+ sep = "\n\n"
538
+ else:
539
+ sep = " "
540
+ combined = sep.join([c.strip() for c in translated_chunks if c and c.strip()])
541
+ # normalize whitespace
542
+ combined = re.sub(r'\s+', ' ', combined).strip()
543
+ return combined
544
+
545
+ # ----------------------- Translator Initialization -----------------------
546
+ translator = MultilingualTranslator(cache_minutes=CACHE_MINUTES, max_workers=MAX_WORKERS)
547
+
548
+ # ----------------------- FastAPI App -----------------------
549
+ app = FastAPI(title="Enhanced Multilingual Translation API", version="2.1.0")
550
  app.add_middleware(
551
  CORSMiddleware,
552
+ allow_origins=["*"], # in production, set your WP domain(s)
553
  allow_credentials=True,
554
  allow_methods=["*"],
555
  allow_headers=["*"],
556
  )
557
 
558
+ # ----------------------- Routes -----------------------
559
  @app.get("/")
560
  async def root():
561
  return {
562
+ "message": "Enhanced Multilingual Translation API v2.1",
563
+ "status": "active",
564
+ "model": translator.model_name,
565
+ "device": getattr(translator, "device", "cpu"),
566
+ "features": ["cache", "background_tasks", "progress_tracking", "chunking"]
 
 
 
 
567
  }
568
 
569
+ @app.post("/api/translate", response_model=TranslationResponse)
570
  async def api_translate(request: TranslationRequest):
571
+ """
572
+ JSON endpoint for synchronous translation. Waits until translation completes.
573
+ (Suitable for short texts)
574
+ """
575
+ text = request.text or ""
576
+ if not text.strip():
577
  raise HTTPException(status_code=400, detail="No text provided")
578
+
579
+ # map language names to codes if needed
580
+ src_code = LANGUAGE_MAP.get(request.source_lang, request.source_lang)
581
+ tgt_code = LANGUAGE_MAP.get(request.target_lang, request.target_lang)
582
+
583
+ # Run translation (async)
 
584
  try:
585
+ translation, processing_time, chunks_count = await translator.translate_text_async(text, src_code, tgt_code)
 
 
 
 
 
 
586
  return TranslationResponse(
587
  translation=translation,
588
  source_language=request.source_lang,
589
  target_language=request.target_lang,
590
+ processing_time=float(processing_time),
591
+ character_count=len(text),
592
  status="success",
593
+ chunks_processed=chunks_count,
594
+ estimated_time_remaining=0.0,
595
+ current_chunk=chunks_count,
596
+ total_chunks=chunks_count,
597
+ session_id=None
598
  )
599
  except Exception as e:
600
+ logger.exception("[API] translate error")
601
+ raise HTTPException(status_code=500, detail=str(e))
602
 
 
603
  @app.post("/api/translate/form")
604
  async def api_translate_form(request: Request):
605
+ """
606
+ Compatibility endpoint for form-data (used by WP plugin's call).
607
+ Accepts either form-encoded or JSON payload.
608
+ Behavior:
609
+ - If short text -> translate immediately and return translation
610
+ - If long text -> check cache; if cached return result; else schedule background and return session info
611
+ """
612
  try:
613
+ form = await request.form()
614
+ # form() returns a starlette.datastructures.FormData object; fallback if empty
615
+ data = dict(form) if form else {}
616
+ # prefer form fields
617
+ text = data.get("text") or (await request.json()).get("text") if request.headers.get("content-type", "").startswith("application/json") else None
618
+ source_lang = data.get("source_lang") or (await request.json()).get("source_lang") if text is None else data.get("source_lang")
619
+ target_lang = data.get("target_lang") or (await request.json()).get("target_lang") if text is None else data.get("target_lang")
620
+ api_key = data.get("api_key") or None
621
+ except Exception:
622
+ # fallback: try json directly
623
  try:
624
+ payload = await request.json()
625
+ text = payload.get("text", "")
626
+ source_lang = payload.get("source_lang", "")
627
+ target_lang = payload.get("target_lang", "")
628
+ api_key = payload.get("api_key", None)
629
+ except Exception:
630
  raise HTTPException(status_code=400, detail="Invalid request format")
631
+
632
+ text = text or ""
633
+ source_lang = source_lang or ""
634
+ target_lang = target_lang or ""
635
+
636
+ logger.info(f"[FORM API] Request: {len(text)} chars | {source_lang} -> {target_lang}")
637
+
638
  if not text.strip():
 
639
  return {"status": "error", "message": "No text provided"}
640
+
641
+ src_code = LANGUAGE_MAP.get(source_lang, source_lang)
642
+ tgt_code = LANGUAGE_MAP.get(target_lang, target_lang)
643
+
644
+ # Generate session id
645
+ session_id = generate_session_id("req")
646
+
647
+ # If long text -> background
 
 
 
 
648
  if len(text) > translator.max_chunk_size:
649
+ # Check full-text cache first
650
+ cached_full = translator.cache.get(text, src_code, tgt_code)
651
+ if cached_full:
652
+ logger.info(f"[FORM API] returning cached full result for session {session_id}")
653
  return {
654
+ "translation": cached_full,
 
 
655
  "processing_time": 0.0,
656
  "character_count": len(text),
657
  "status": "success",
658
  "chunks_processed": None,
659
  "session_id": session_id,
 
660
  "cached": True
661
  }
662
+
663
+ # schedule background translation
664
+ # ensure we schedule within event loop
665
+ loop = asyncio.get_event_loop()
666
+ task = loop.create_task(translator._bg_task_wrapper(text, src_code, tgt_code, session_id))
667
  translator.background_tasks[session_id] = task
668
+ logger.info(f"[FORM API] background scheduled session {session_id}")
 
 
669
  return {
670
  "session_id": session_id,
671
  "request_id": session_id,
672
  "status": "processing",
673
+ "message": "Translation started in background. Use /api/status/{session_id} or /api/progress/{session_id} to check.",
674
  "character_count": len(text),
675
  "is_background": True,
676
  "is_heavy_text": True
677
  }
678
  else:
679
+ # short text - translate immediately
680
  try:
681
+ translation, processing_time, chunks_count = await translator.translate_text_async(text, src_code, tgt_code, session_id)
682
+ # validate
683
+ if not translation or (isinstance(translation, str) and translation.lower().startswith("translation error")):
684
+ logger.error("[FORM API] Invalid translation result")
685
+ return {"status": "error", "message": "Translation failed - empty or invalid result", "session_id": session_id}
 
 
 
 
 
 
 
 
 
686
  return {
687
  "translation": translation,
688
  "source_language": source_lang,
 
691
  "character_count": len(text),
692
  "status": "success",
693
  "chunks_processed": chunks_count,
694
+ "session_id": session_id,
695
+ "cached": False
696
  }
697
  except Exception as e:
698
+ logger.exception("[FORM API] translation error")
699
  return {"status": "error", "message": f"Translation error: {str(e)}"}
700
 
701
+ @app.get("/api/status/{session_id}")
702
+ async def get_session_status(session_id: str):
703
+ """Return completed result if available, or processing state."""
704
+ # check background tasks dict first
705
+ bg = translator.background_tasks.get(session_id)
706
+ if bg:
707
+ if bg.done():
708
+ res = getattr(bg, "result_data", None)
709
+ if res:
710
+ return {
711
+ "status": "completed",
712
+ "translation": res.get("translation"),
713
+ "processing_time": res.get("processing_time"),
714
+ "chunks_processed": res.get("chunks"),
715
+ "character_count": res.get("character_count")
716
+ }
717
+ else:
718
+ return {"status": "completed", "message": "Completed but no data"}
719
+ else:
720
+ progress = translator.get_translation_progress(session_id)
721
+ return {"status": "processing", "progress": progress}
722
+
723
+ # else check current_translation (in-progress immediate)
724
+ prog = translator.get_translation_progress(session_id)
725
+ if prog:
726
+ return {"status": "processing", "progress": prog}
727
+ return {"status": "not_found", "message": "Session not found or already cleaned up"}
728
+
729
  @app.get("/api/progress/{session_id}")
730
  async def get_translation_progress(session_id: str):
731
+ p = translator.get_translation_progress(session_id)
732
+ if p is None:
 
733
  raise HTTPException(status_code=404, detail="Session not found or completed")
734
+ return {"status": "success", "progress": p}
735
+
736
+ @app.get("/api/result/{session_id}")
737
+ async def get_result(session_id: str):
738
+ # check background
739
+ bg = translator.background_tasks.get(session_id)
740
+ if bg and bg.done():
741
+ res = getattr(bg, "result_data", None)
742
+ if res:
743
+ return {
744
+ "status": "success",
745
+ "translation": res.get("translation"),
746
+ "processing_time": res.get("processing_time"),
747
+ "character_count": res.get("character_count"),
748
+ "chunks_processed": res.get("chunks"),
749
+ "session_id": session_id
750
+ }
751
+ else:
752
+ return {"status": "error", "message": "Completed but no result data"}
753
+
754
+ # if still processing
755
+ prog = translator.get_translation_progress(session_id)
756
+ if prog:
757
+ return {"status": "processing", "progress": prog}
758
+
759
+ # maybe not found
760
+ raise HTTPException(status_code=404, detail="Session not found")
761
 
762
  @app.get("/api/languages")
763
  async def get_languages():
764
+ return {"languages": list(LANGUAGE_MAP.keys()), "language_codes": LANGUAGE_MAP, "status": "success"}
 
 
 
 
 
765
 
766
  @app.get("/api/health")
767
  async def health_check():
 
768
  return {
769
  "status": "healthy",
770
+ "device": getattr(translator, "device", "cpu"),
771
+ "model": getattr(translator, "model_name", None),
772
  "cache_size": len(translator.cache.cache),
773
  "max_chunk_size": translator.max_chunk_size,
774
  "active_translations": len(translator.current_translation),
775
+ "background_tasks": len(translator.background_tasks),
776
  "version": "2.1.0"
777
  }
778
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
779
  @app.get("/api/server-status")
780
  async def get_server_status():
 
781
  active_sessions = []
 
 
782
  with translator.translation_lock:
783
+ for sid, progress in translator.current_translation.items():
784
+ elapsed = time.time() - progress['start_time']
785
+ completed = progress.get('completed_chunks', 0)
786
+ total = progress.get('total_chunks', 1)
787
+ percent = (completed / total) * 100 if total else 0
 
 
 
 
788
  active_sessions.append({
789
+ "session_id": sid,
790
+ "source_lang": progress.get('source_lang'),
791
+ "target_lang": progress.get('target_lang'),
792
+ "total_chunks": total,
793
+ "completed_chunks": completed,
794
+ "progress_percentage": percent,
795
+ "elapsed_time": elapsed
 
796
  })
797
+ bg_count = len(translator.background_tasks)
798
+ return {
799
+ "has_active_translation": bool(active_sessions) or bg_count > 0,
800
+ "active_sessions": active_sessions,
801
+ "background_tasks": bg_count,
802
+ "message": f"{len(active_sessions)} active, {bg_count} in background"
803
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
804
 
805
+ # ----------------------- Run -----------------------
806
  if __name__ == "__main__":
807
+ # uvicorn.run(app, host="0.0.0.0", port=7860)
808
+ logger.info(f"Starting server on {SERVER_HOST}:{SERVER_PORT} (model={translator.model_name})")
809
+ uvicorn.run("server:app", host=SERVER_HOST, port=SERVER_PORT, log_level="info", reload=False)