Spaces:
Sleeping
Sleeping
| import logging | |
| import random | |
| import json | |
| import asyncio | |
| import time | |
| import httpx | |
| import re | |
| import html | |
| from typing import List, Dict, Any, Union | |
| from openai import AsyncOpenAI | |
| from src.config import settings | |
| from src.database.models import SessionLocal, VerifiedNews | |
| logger = logging.getLogger(__name__) | |
| GROQ_BASE_URL = "https://api.groq.com/openai/v1" | |
| GROQ_MODEL = "llama-3.3-70b-versatile" | |
| HF_NLLB_URL = "https://api-inference.huggingface.co/models/facebook/nllb-200-distilled-600M" | |
| class NewsTranslator: | |
| def __init__(self): | |
| # 1. Gather unique non-empty keys from settings pools | |
| self.openai_keys = list(dict.fromkeys([k for k in settings.OPENAI_API_KEYS if k])) | |
| self.groq_keys = list(dict.fromkeys([k for k in settings.GROQ_API_KEYS if k])) | |
| # Combined Pool for high-speed rotation | |
| self.all_keys = self.openai_keys + self.groq_keys | |
| self.current_key_idx = 0 | |
| # 2. Key Status Tracking (To prevent spamming dead/limited keys) | |
| self._key_status = {} | |
| # REDUCED CONCURRENCY: Higher stability | |
| self._concurrency_limit = asyncio.Semaphore(5) | |
| # NLLB Language Mapping | |
| self.nllb_map = { | |
| "Telugu": "tel_Telu", "Hindi": "hin_Deva", "Tamil": "tam_Taml", | |
| "Kannada": "kan_Knda", "Malayalam": "mal_Mlym", "Marathi": "mar_Deva", | |
| "Bengali": "ben_Beng", "Gujarati": "guj_Gujr", "Arabic": "arb_Arab", | |
| "Japanese": "jpn_Jpan", "Spanish": "spa_Latn", "French": "fra_Latn", | |
| "German": "deu_Latn", "Russian": "rus_Cyrl", "Chinese": "zho_Hans", | |
| "Korean": "kor_Hang", "Portuguese": "por_Latn", "Turkish": "tur_Latn", | |
| "Punjabi": "pan_Guru", "Italian": "ita_Latn", "Vietnamese": "vie_Latn", | |
| "Indonesian": "ind_Latn", "Odia": "ory_Orya", "Assamese": "asm_Beng", | |
| "Dutch": "nld_Latn", "Swedish": "swe_Latn", "Thai": "tha_Thai", | |
| "Polish": "pol_Latn", "Urdu": "urd_Arab", | |
| "Ukrainian": "ukr_Cyrl", "Persian": "pes_Arab", "Greek": "ell_Grek", | |
| "Romanian": "ron_Latn", "Tagalog": "tgl_Latn", "Malay": "zsm_Latn", | |
| "Hebrew": "heb_Hebr", "Finnish": "fin_Latn", "Hungarian": "hun_Latn", | |
| "Czech": "ces_Latn" | |
| } | |
| # Google Translate Language Mapping | |
| self.google_lang_map = { | |
| "Telugu": "te", "Hindi": "hi", "Tamil": "ta", | |
| "Kannada": "kn", "Malayalam": "ml", "Marathi": "mr", | |
| "Bengali": "bn", "Gujarati": "gu", "Arabic": "ar", | |
| "Japanese": "ja", "Spanish": "es", "French": "fr", | |
| "German": "de", "Russian": "ru", "Chinese": "zh", | |
| "Korean": "ko", "Portuguese": "pt", "Turkish": "tr", | |
| "Punjabi": "pa", "Italian": "it", "Vietnamese": "vi", | |
| "Indonesian": "id", "Odia": "or", "Assamese": "as", | |
| "Dutch": "nl", "Swedish": "sv", "Thai": "th", | |
| "Polish": "pl", "Urdu": "ur", | |
| "Ukrainian": "uk", "Persian": "fa", "Greek": "el", | |
| "Romanian": "ro", "Tagalog": "tl", "Malay": "ms", | |
| "Hebrew": "he", "Finnish": "fi", "Hungarian": "hu", | |
| "Czech": "cs" | |
| } | |
| if not self.all_keys: | |
| logger.warning("No API keys found for NewsTranslator. Translation will be skipped.") | |
| else: | |
| logger.info(f"NewsTranslator initialized with {len(self.all_keys)} keys.") | |
| self._clients: Dict[str, AsyncOpenAI] = {} | |
| # NVIDIA Client setup | |
| self.nvidia_keys = getattr(settings, "NVIDIA_API_KEYS", []) | |
| self.nvidia_clients = [ | |
| AsyncOpenAI(base_url="https://integrate.api.nvidia.com/v1", api_key=k) | |
| for k in self.nvidia_keys | |
| ] | |
| # Keep single nvidia_client for backward compatibility | |
| self.nvidia_client = self.nvidia_clients[0] if self.nvidia_clients else None | |
| if self.nvidia_clients: | |
| logger.info(f"NVIDIA API client initialized with {len(self.nvidia_clients)} keys for translation fallback.") | |
| else: | |
| logger.warning("No NVIDIA keys found. NVIDIA translation fallback will be disabled.") | |
| # 3. External Cache (for items with ID 0) | |
| from pathlib import Path | |
| self.external_cache_path = settings.DATA_DIR / "external_translations.json" | |
| self._external_cache = {} | |
| self._load_external_cache() | |
| def _load_external_cache(self): | |
| try: | |
| if self.external_cache_path.exists(): | |
| with open(self.external_cache_path, "r", encoding="utf-8") as f: | |
| self._external_cache = json.load(f) | |
| except Exception as e: | |
| logger.error(f"Failed to load external translation cache: {e}") | |
| def _save_external_cache(self): | |
| try: | |
| # Basic pruning: keep it under 5000 items | |
| if len(self._external_cache) > 5000: | |
| keys = list(self._external_cache.keys()) | |
| self._external_cache = {k: self._external_cache[k] for k in keys[-3000:]} | |
| with open(self.external_cache_path, "w", encoding="utf-8") as f: | |
| json.dump(self._external_cache, f, ensure_ascii=False, indent=2) | |
| except Exception as e: | |
| logger.error(f"Failed to save external translation cache: {e}") | |
| def is_already_in_lang(self, text: str, target_lang: str) -> bool: | |
| if not text or not target_lang: | |
| return False | |
| lang_unicode_ranges = { | |
| "Hindi": r"[\u0900-\u097F]", | |
| "Telugu": r"[\u0c00-\u0c7F]", | |
| "Tamil": r"[\u0b80-\u0bFF]", | |
| "Kannada": r"[\u0c80-\u0cFF]", | |
| "Malayalam": r"[\u0d00-\u0d7F]", | |
| "Bengali": r"[\u0980-\u09FF]", | |
| "Gujarati": r"[\u0a80-\u0aFF]", | |
| "Arabic": r"[\u0600-\u06FF]", | |
| "Japanese": r"[\u3040-\u30FF\u31F0-\u31FF\u4E00-\u9FFF]", | |
| "Chinese": r"[\u4E00-\u9FFF]", | |
| "Korean": r"[\uAC00-\uD7AF\u1100-\u11FF]", | |
| "Russian": r"[\u0400-\u04FF]", | |
| "Punjabi": r"[\u0A00-\u0A7F]", | |
| "Odia": r"[\u0B00-\u0B7F]", | |
| "Assamese": r"[\u0980-\u09FF]", | |
| "Thai": r"[\u0E00-\u0E7F]", | |
| "Urdu": r"[\u0600-\u06FF]" | |
| } | |
| pattern = lang_unicode_ranges.get(target_lang.capitalize()) or lang_unicode_ranges.get(target_lang) | |
| if not pattern: | |
| return False | |
| # If the text has any characters matching the target language's script, check the count | |
| matches = re.findall(pattern, text) | |
| if not matches: | |
| return False | |
| # If more than 3 characters (or 5% of non-whitespace characters) match the script, skip translation | |
| non_ws_len = len([c for c in text if not c.isspace()]) | |
| if non_ws_len == 0: | |
| return False | |
| percent_match = len(matches) / non_ws_len | |
| return len(matches) > 3 or percent_match > 0.05 | |
| async def translate_nvidia(self, text: str, target_lang: str, attempts: int = 3) -> str: | |
| """ | |
| Layer 3: NVIDIA API Catalog translation layer (High-speed, premium quality). | |
| Used as fallback/primary high-speed translation layer. | |
| """ | |
| if not text or not target_lang: | |
| return text | |
| if not self.nvidia_clients: | |
| return text | |
| for i in range(attempts): | |
| client = self.nvidia_clients[i % len(self.nvidia_clients)] | |
| try: | |
| response = await client.chat.completions.create( | |
| model="meta/llama-3.3-70b-instruct", | |
| messages=[ | |
| { | |
| "role": "system", | |
| "content": f"You are a master news journalist and professional translator. Translate the text to {target_lang}. RETURN ONLY THE TRANSLATED TEXT. DO NOT explain, do not add introductory text, do not add quotes, just return the translated text." | |
| }, | |
| {"role": "user", "content": text} | |
| ], | |
| temperature=0.2, | |
| top_p=0.7, | |
| max_tokens=1024 | |
| ) | |
| return response.choices[0].message.content.strip() | |
| except Exception as e: | |
| logger.error(f"NVIDIA translation attempt {i+1} failed with client index {i % len(self.nvidia_clients)}: {e}") | |
| if i < attempts - 1: | |
| await asyncio.sleep(1) | |
| return text | |
| async def translate_microsoft(self, text: str, target_lang: str, attempts: int = 3) -> str: | |
| """ | |
| Layer 3.5: Microsoft Translator API (Cognitive Services translation layer). | |
| High-speed, premium quality fallback. | |
| """ | |
| if not text or not target_lang: | |
| return text | |
| key = getattr(settings, "MICROSOFT_TRANSLATOR_KEY", None) | |
| region = getattr(settings, "MICROSOFT_TRANSLATOR_REGION", "eastasia") | |
| endpoint = getattr(settings, "MICROSOFT_TRANSLATOR_ENDPOINT", "https://api.cognitive.microsofttranslator.com/") | |
| if not key: | |
| return text | |
| ms_code = self.google_lang_map.get(target_lang.capitalize()) or self.google_lang_map.get(target_lang) | |
| if not ms_code: | |
| return text | |
| url = f"{endpoint.rstrip('/')}/translate" | |
| params = { | |
| 'api-version': '3.0', | |
| 'to': ms_code | |
| } | |
| headers = { | |
| 'Ocp-Apim-Subscription-Key': key, | |
| 'Ocp-Apim-Subscription-Region': region, | |
| 'Content-Type': 'application/json' | |
| } | |
| body = [{'text': text}] | |
| for i in range(attempts): | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| response = await client.post(url, params=params, headers=headers, json=body) | |
| if response.status_code == 200: | |
| result = response.json() | |
| if result and isinstance(result, list) and len(result) > 0: | |
| trans_list = result[0].get("translations", []) | |
| if trans_list: | |
| return trans_list[0].get("text", text) | |
| else: | |
| logger.warning(f"Microsoft Translator API returned error {response.status_code}: {response.text}") | |
| except Exception as e: | |
| logger.error(f"Microsoft translation attempt {i+1} failed: {e}") | |
| return text | |
| async def translate_deepl(self, text: str, target_lang: str, attempts: int = 3) -> str: | |
| """ | |
| Layer 2.8: DeepL Translator API. | |
| High-speed, premium quality fallback. | |
| """ | |
| if not text or not target_lang: | |
| return text | |
| key = getattr(settings, "DEEPL_API_KEY", None) | |
| if not key: | |
| return text | |
| deepl_map = { | |
| "Telugu": None, "Hindi": "HI", "Tamil": None, "Kannada": None, "Malayalam": None, | |
| "Marathi": None, "Bengali": None, "Gujarati": None, "Arabic": "AR", | |
| "Japanese": "JA", "Spanish": "ES", "French": "FR", "German": "DE", | |
| "Russian": "RU", "Chinese": "ZH", "Korean": "KO", "Portuguese": "PT", | |
| "Turkish": "TR", "Punjabi": None, "Italian": "IT", "Vietnamese": None, | |
| "Indonesian": "ID", "Odia": None, "Assamese": None, "Dutch": "NL", | |
| "Swedish": "SV", "Thai": None, "Polish": "PL", "Urdu": None, | |
| "Ukrainian": "UK", "Persian": None, "Greek": "EL", "Romanian": "RO", | |
| "Tagalog": None, "Malay": None, "Hebrew": None, "Finnish": "FI", | |
| "Hungarian": "HU", "Czech": "CS" | |
| } | |
| lang_code = deepl_map.get(target_lang.capitalize()) or deepl_map.get(target_lang) | |
| if not lang_code: | |
| logger.info(f"Language '{target_lang}' is not supported by DeepL. Skipping to next layer.") | |
| return text | |
| is_free = key.endswith(":fx") | |
| endpoint = "https://api-free.deepl.com/v2/translate" if is_free else "https://api.deepl.com/v2/translate" | |
| headers = { | |
| "Authorization": f"DeepL-Auth-Key {key}", | |
| "Content-Type": "application/json" | |
| } | |
| payload = { | |
| "text": [text], | |
| "target_lang": lang_code | |
| } | |
| for i in range(attempts): | |
| try: | |
| import httpx | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| response = await client.post(endpoint, headers=headers, json=payload) | |
| if response.status_code == 200: | |
| data = response.json() | |
| translations = data.get("translations", []) | |
| if translations: | |
| return translations[0].get("text", text) | |
| else: | |
| logger.warning(f"DeepL API returned error {response.status_code}: {response.text}") | |
| except Exception as e: | |
| logger.error(f"DeepL translation attempt {i+1} failed: {e}") | |
| if i < attempts - 1: | |
| await asyncio.sleep(1) | |
| return text | |
| async def verify_all_keys(self) -> Dict[str, Any]: | |
| """ | |
| Hardcore check: verifies every OpenAI and Groq key in the pool. | |
| Returns a detailed report of active/dead keys. | |
| """ | |
| logger.info("Starting hardcore key health verification...") | |
| results = {"active": [], "dead": [], "limited": []} | |
| async def check_single_key(key): | |
| client, provider = self._get_client_by_key(key) | |
| model = "gpt-4o-mini" if provider == "OpenAI" else GROQ_MODEL | |
| try: | |
| # Minimum prompt to save tokens | |
| await client.chat.completions.create( | |
| model=model, | |
| messages=[{"role": "user", "content": "hi"}], | |
| max_tokens=5, | |
| timeout=10 | |
| ) | |
| self._key_status[key] = {"status": "active", "retry_after": 0} | |
| return key, provider, "active" | |
| except Exception as e: | |
| error_msg = str(e).lower() | |
| is_rate = any(word in error_msg for word in ["429", "rate limit", "rate_limit", "throttle", "too many requests"]) and "insufficient_quota" not in error_msg and "quota exceeded" not in error_msg | |
| is_quota = not is_rate and any(word in error_msg for word in ["quota", "insufficient", "spend", "invalid", "deactivated", "disabled", "revoked", "billing"]) | |
| status = "dead" if is_quota else ("limited" if is_rate else "error") | |
| self._mark_key_limited(key, is_dead=(status == "dead")) | |
| return key, provider, status | |
| tasks = [check_single_key(k) for k in self.all_keys] | |
| checks = await asyncio.gather(*tasks) | |
| for key, provider, status in checks: | |
| short_key = f"{key[:6]}...{key[-4:]}" | |
| if status == "active": | |
| results["active"].append(f"{provider}: {short_key}") | |
| elif status == "dead": | |
| results["dead"].append(f"{provider}: {short_key}") | |
| else: | |
| results["limited"].append(f"{provider}: {short_key}") | |
| logger.info(f"Verification complete: {len(results['active'])} active, {len(results['dead'])} dead, {len(results['limited'])} limited.") | |
| return results | |
| async def translate_nllb(self, text: str, target_lang: str, attempts: int = 3) -> str: | |
| """ | |
| Layer 3: Emergency Fallback using NLLB via Hugging Face. | |
| Zero cost, infinite availability (within rate limits). | |
| Enhanced with robust retry logic and error handling. | |
| """ | |
| if not text or not target_lang: | |
| return text | |
| if not settings.HUGGINGFACE_API_KEY: | |
| return text | |
| nllb_code = self.nllb_map.get(target_lang.capitalize()) or self.nllb_map.get(target_lang) | |
| if not nllb_code: | |
| return text | |
| headers = {"Authorization": f"Bearer {settings.HUGGINGFACE_API_KEY}"} | |
| payload = { | |
| "inputs": text, | |
| "parameters": {"src_lang": "eng_Latn", "tgt_lang": nllb_code} | |
| } | |
| for i in range(attempts): | |
| try: | |
| async with httpx.AsyncClient(timeout=45) as client: | |
| response = await client.post(HF_NLLB_URL, headers=headers, json=payload) | |
| if response.status_code == 200: | |
| result = response.json() | |
| if isinstance(result, list) and len(result) > 0: | |
| return result[0].get("translation_text", text) | |
| return text | |
| if response.status_code == 503: # Model loading | |
| wait_time = (i + 1) * 3 | |
| logger.info(f"NLLB model loading (503). Waiting {wait_time}s before retry {i+1}/{attempts}...") | |
| await asyncio.sleep(wait_time) | |
| continue | |
| if response.status_code == 429: # Rate limit | |
| wait_time = (i + 1) * 5 | |
| logger.warning(f"NLLB rate limited (429). Waiting {wait_time}s before retry {i+1}/{attempts}...") | |
| await asyncio.sleep(wait_time) | |
| continue | |
| logger.warning(f"NLLB failed with status {response.status_code}: {response.text}") | |
| break # Don't retry for other errors (e.g. 400, 401) | |
| except Exception as e: | |
| logger.error(f"NLLB attempt {i+1} failed with error: {e}") | |
| if i < attempts - 1: | |
| await asyncio.sleep(2) | |
| continue | |
| return text | |
| def _get_best_key(self): | |
| """Selects the next available key from the rotation pool, prioritizing premium keys but using random offsets to avoid parallel collisions.""" | |
| now = time.time() | |
| premium_openai = [settings.OPENAI_KEY_1, settings.OPENAI_KEY_2, settings.OPENAI_KEY_3] | |
| premium_groq = [settings.GROQ_KEY_1, settings.GROQ_KEY_2] | |
| all_others = [k for k in self.all_keys if k not in premium_openai and k not in premium_groq] | |
| priority_queue = [k for k in premium_openai if k] + [k for k in premium_groq if k] + all_others | |
| num_keys = len(priority_queue) | |
| if num_keys == 0: | |
| return None, None | |
| # Start scanning from a random offset to prevent concurrent requests from picking the same key | |
| start_idx = random.randint(0, num_keys - 1) | |
| for i in range(num_keys): | |
| idx = (start_idx + i) % num_keys | |
| key = priority_queue[idx] | |
| status = self._key_status.get(key, {"status": "active", "retry_after": 0}) | |
| if status["status"] == "dead": continue | |
| if status["status"] == "cooled_down": | |
| if now < status["retry_after"]: continue | |
| else: self._key_status[key] = {"status": "active", "retry_after": 0} | |
| return key, idx | |
| return None, None | |
| def _clean_json(self, text_content): | |
| """Search for and extract valid JSON from a mixed-text response with high recovery.""" | |
| if not text_content: return None | |
| try: | |
| clean = text_content.strip() | |
| # Remove Markdown code blocks if present | |
| if "```json" in clean: | |
| clean = clean.split("```json")[1].split("```")[0].strip() | |
| elif "```" in clean: | |
| clean = clean.split("```")[1].strip() | |
| # Find the first { and last } | |
| start = clean.find('{') | |
| end = clean.rfind('}') | |
| if start != -1 and end != -1: | |
| clean = clean[start:end+1] | |
| # Remove trailing commas before closing braces/brackets | |
| import re | |
| clean = re.sub(r',\s*([\]}])', r'\1', clean) | |
| # Handle common LLM escape character issues | |
| clean = clean.replace('\\"', '"').replace('\\n', ' ') | |
| # Fix double quotes inside strings (basic attempt) | |
| # clean = re.sub(r'(?<![:\[,])"(?![:,\]])', "'", clean) | |
| return json.loads(clean) | |
| except Exception as e: | |
| # Layer 2: Attempt even more aggressive cleaning if Layer 1 fails | |
| try: | |
| # Replace newlines in strings which break json.loads | |
| clean = re.sub(r'\n', ' ', clean) | |
| return json.loads(clean) | |
| except: | |
| logger.warning(f"JSON extraction failed: {e}. Raw: {text_content[:150]}...") | |
| return None | |
| def _mark_key_limited(self, key, is_dead=False): | |
| if is_dead: | |
| self._key_status[key] = {"status": "dead", "retry_after": 0} | |
| else: | |
| self._key_status[key] = {"status": "cooled_down", "retry_after": time.time() + 30} | |
| def _get_client_by_key(self, key): | |
| if not key: return None, "None" | |
| is_groq = key.startswith("gsk_") | |
| if key not in self._clients: | |
| if is_groq: | |
| self._clients[key] = AsyncOpenAI(api_key=key, base_url=GROQ_BASE_URL, max_retries=0) | |
| else: | |
| self._clients[key] = AsyncOpenAI(api_key=key, base_url="https://api.openai.com/v1", max_retries=0) | |
| provider = "Groq" if is_groq else "OpenAI" | |
| return self._clients[key], provider | |
| async def translate_text(self, text: str, target_lang: str) -> str: | |
| """Translate a single piece of text to target_lang with 4-layer failover.""" | |
| if not text or not target_lang or target_lang.lower() == 'english': | |
| return text | |
| # Task 3: Language Detection Skip | |
| if self.is_already_in_lang(text, target_lang): | |
| logger.info(f"Skipping translation: text already in target language '{target_lang}'") | |
| return text | |
| # 1. Prepare candidate pools (Layer 1 & 2: OpenAI & Groq) | |
| attempt_pools = [] | |
| if self.openai_keys: attempt_pools.append(("openai", self.openai_keys)) | |
| if self.groq_keys: attempt_pools.append(("groq", self.groq_keys)) | |
| for provider, keys in attempt_pools: | |
| shuffled_keys = list(keys) | |
| random.shuffle(shuffled_keys) | |
| for i, key in enumerate(shuffled_keys): | |
| status_info = self._key_status.get(key, {"status": "active"}) | |
| if status_info["status"] == "dead": continue | |
| if status_info["status"] == "cooled_down" and time.time() < status_info.get("retry_after", 0): | |
| continue | |
| try: | |
| client, _ = self._get_client_by_key(key) | |
| model = "gpt-4o-mini" if provider == "openai" else GROQ_MODEL | |
| response = await client.chat.completions.create( | |
| model=model, | |
| messages=[ | |
| { | |
| "role": "system", | |
| "content": f"You are a master news journalist and professional translator. Translate to {target_lang}. RETURN ONLY THE TRANSLATED TEXT." | |
| }, | |
| {"role": "user", "content": text} | |
| ], | |
| temperature=0.1, | |
| timeout=30 # Increased for stability | |
| ) | |
| return response.choices[0].message.content.strip() | |
| except Exception as e: | |
| error_msg = str(e).lower() | |
| is_rate = any(word in error_msg for word in ["429", "rate limit", "rate_limit", "throttle", "too many requests"]) and "insufficient_quota" not in error_msg and "quota exceeded" not in error_msg | |
| is_quota = not is_rate and any(word in error_msg for word in ["quota", "insufficient", "spend", "invalid", "deactivated", "disabled", "revoked", "billing"]) | |
| if is_quota or is_rate: | |
| self._mark_key_limited(key, is_dead=is_quota) | |
| continue | |
| # 1.8 Layer 2.8: DeepL Translator API (High-performance fallback, placed above Microsoft) | |
| try: | |
| logger.info(f"LLM layers failed. Attempting DeepL translation for target lang: {target_lang}") | |
| translated = await self.translate_deepl(text, target_lang) | |
| if translated and translated != text: | |
| return translated | |
| except Exception as de: | |
| logger.warning(f"DeepL translation layer failed: {de}") | |
| # 2. Layer 3: Microsoft Translator API (Cognitive Services fallback) | |
| try: | |
| logger.info(f"LLM layers failed. Attempting Microsoft translation for target lang: {target_lang}") | |
| translated = await self.translate_microsoft(text, target_lang) | |
| if translated and translated != text: | |
| return translated | |
| except Exception as me: | |
| logger.warning(f"Microsoft translation layer failed: {me}") | |
| # 2.5 Layer 3.5: NVIDIA API (High-performance fallback) | |
| try: | |
| logger.info(f"Microsoft layer failed. Attempting NVIDIA translation for target lang: {target_lang}") | |
| translated = await self.translate_nvidia(text, target_lang) | |
| if translated and translated != text: | |
| return translated | |
| except Exception as ne: | |
| logger.warning(f"NVIDIA translation layer failed: {ne}") | |
| # 3. Layer 4: NLLB (Final emergency fallback) | |
| if target_lang in self.nllb_map: | |
| logger.warning(f"LLM, Microsoft & NVIDIA layers failed for {target_lang}. Using NLLB fallback.") | |
| return await self.translate_nllb(text, target_lang) | |
| return text | |
| async def translate_stories(self, stories: List[Dict[str, Any]], target_lang: str) -> List[Dict[str, Any]]: | |
| """Translate multiple stories with parallel execution and DB caching.""" | |
| if not stories or not target_lang or target_lang.lower() == 'english': | |
| return stories | |
| target_lang = target_lang.strip().capitalize() | |
| translated_stories = json.loads(json.dumps(stories)) | |
| node_data = {"stories": translated_stories} | |
| await self.translate_node_bulk(node_data, target_lang) | |
| return node_data.get("stories", translated_stories) | |
| async def translate_node_bulk(self, node_data: Dict[str, Any], target_lang: str) -> Dict[str, Any]: | |
| """Translate entire node dashboard with DB caching and NLLB fallback.""" | |
| if not target_lang or target_lang.lower() == 'english': | |
| return node_data | |
| stories = node_data.get("stories", []) | |
| if not stories: | |
| return node_data | |
| def _load_cache_sync(): | |
| db = SessionLocal() | |
| u_indices = [] | |
| try: | |
| # Batch retrieve all articles in a single query | |
| article_ids = [] | |
| for story in stories: | |
| art_id = story.get("id") | |
| if art_id and str(art_id).isdigit(): | |
| article_ids.append(int(art_id)) | |
| articles_map = {} | |
| if article_ids: | |
| db_articles = db.query(VerifiedNews).filter(VerifiedNews.id.in_(article_ids)).all() | |
| articles_map = {a.id: a for a in db_articles} | |
| for idx, story in enumerate(stories): | |
| # Task 3: Language Detection Skip | |
| if self.is_already_in_lang(story.get("title", ""), target_lang): | |
| story.update({ | |
| "is_translated": True, | |
| "is_cached": True | |
| }) | |
| continue | |
| article_id = story.get("id") | |
| if article_id and str(article_id).isdigit(): | |
| article = articles_map.get(int(article_id)) | |
| if article and article.translation_cache: | |
| cache = article.translation_cache | |
| if isinstance(cache, str): | |
| try: cache = json.loads(cache) | |
| except: cache = {} | |
| if target_lang in cache: | |
| cached_val = cache[target_lang] | |
| cached_title = cached_val.get("title") or story.get("title") or story.get("headline") | |
| cached_bullets = cached_val.get("bullets") or story.get("bullets") or story.get("summary_bullets") or [] | |
| cached_why = cached_val.get("why") or story.get("why") or story.get("why_it_matters") or story.get("summary") or "" | |
| cached_affected = cached_val.get("affected") or story.get("affected") or story.get("who_is_affected") or "" | |
| story.update({ | |
| "title": cached_title, | |
| "headline": cached_title, | |
| "bullets": cached_bullets, | |
| "summary_bullets": cached_bullets, | |
| "why": cached_why, | |
| "why_it_matters": cached_why, | |
| "summary": cached_why, | |
| "affected": cached_affected, | |
| "who_is_affected": cached_affected, | |
| "is_cached": True, | |
| "is_translated": True | |
| }) | |
| continue | |
| # 2. Check External Cache (URL-based) if ID is 0 | |
| if str(article_id) == "0" or not article_id: | |
| url = story.get("url") | |
| cache_key = f"{url}_{target_lang}" if url else None | |
| if cache_key and cache_key in self._external_cache: | |
| cached_val = self._external_cache[cache_key] | |
| cached_title = cached_val.get("t") or story.get("title") or story.get("headline") | |
| cached_bullets = cached_val.get("b") or story.get("bullets") or story.get("summary_bullets") or [] | |
| cached_why = cached_val.get("w") or story.get("why") or story.get("why_it_matters") or story.get("summary") or "" | |
| cached_affected = cached_val.get("a") or story.get("affected") or story.get("who_is_affected") or "" | |
| story.update({ | |
| "title": cached_title, | |
| "headline": cached_title, | |
| "bullets": cached_bullets, | |
| "summary_bullets": cached_bullets, | |
| "why": cached_why, | |
| "why_it_matters": cached_why, | |
| "summary": cached_why, | |
| "affected": cached_affected, | |
| "who_is_affected": cached_affected, | |
| "is_cached": True, | |
| "is_translated": True | |
| }) | |
| continue | |
| u_indices.append(idx) | |
| finally: | |
| db.close() | |
| return u_indices | |
| try: | |
| untranslated_indices = await asyncio.to_thread(_load_cache_sync) | |
| if not untranslated_indices: | |
| return node_data | |
| to_translate_full = [stories[i] for i in untranslated_indices] | |
| batch_size = 4 | |
| batches = [to_translate_full[i:i + batch_size] for i in range(0, len(to_translate_full), batch_size)] | |
| async def translate_batch(batch_items, b_idx): | |
| # BATCH FAILBACK: Parallel single-item translation fallback | |
| async def translate_item_safe(item): | |
| try: | |
| title = item.get("title") or item.get("headline") | |
| bullets = item.get("bullets") or item.get("summary_bullets") or [] | |
| if isinstance(bullets, str): | |
| bullets = [bullets] | |
| why = item.get("why") or item.get("why_it_matters") or item.get("summary") | |
| affected = item.get("affected") or item.get("who_is_affected") | |
| t_task = self.translate_text(title, target_lang) | |
| b_tasks = [self.translate_text(b, target_lang) for b in bullets] | |
| w_task = self.translate_text(why, target_lang) | |
| a_task = self.translate_text(affected, target_lang) | |
| t, b, w, a = await asyncio.gather(t_task, asyncio.gather(*b_tasks), w_task, a_task) | |
| return {"id": item.get("id"), "t": t, "b": b, "w": w, "a": a} | |
| except Exception as e: | |
| logger.error(f"Single item fallback failed: {e}") | |
| return {"id": item.get("id"), "t": item.get("title") or item.get("headline"), "b": item.get("bullets") or item.get("summary_bullets") or [], "w": item.get("why") or item.get("why_it_matters") or item.get("summary"), "a": item.get("affected") or item.get("who_is_affected")} | |
| async with self._concurrency_limit: | |
| key, k_idx = self._get_best_key() | |
| if not key: | |
| logger.warning("No active LLM keys available for batch translation. Falling back directly to individual translators (DeepL/Microsoft).") | |
| return await asyncio.gather(*[translate_item_safe(item) for item in batch_items]) | |
| client, provider = self._get_client_by_key(key) | |
| await asyncio.sleep(b_idx * 0.4) | |
| articles_text = "" | |
| for idx, story in enumerate(batch_items, 1): | |
| bullets = story.get("bullets") or story.get("summary_bullets") or [] | |
| if isinstance(bullets, str): | |
| bullets = [bullets] | |
| why = story.get("why") or story.get("why_it_matters") or story.get("summary") or "N/A" | |
| affected = story.get("affected") or story.get("who_is_affected") or "N/A" | |
| title = story.get("title") or story.get("headline") or "" | |
| articles_text += f"ID: {story.get('id', idx)}\nT: {title}\nB: {' | '.join(bullets)}\nW: {why}\nA: {affected}\n---\n" | |
| max_attempts = 3 # Fast failover for batches | |
| for attempt in range(max_attempts): | |
| try: | |
| batch_model = GROQ_MODEL if provider == "Groq" else "gpt-4o-mini" | |
| response = await client.chat.completions.create( | |
| model=batch_model, | |
| messages=[ | |
| {"role": "system", "content": f"You are a professional news journalist and master translator. Translate the following news articles to {target_lang} using high-quality regional phrasing. Return ONLY a valid JSON object with the structure: {{\"translated\": [ {{ \"id\": \"id\", \"t\": \"title\", \"b\": [\"bullet\"], \"w\": \"why\", \"a\": \"affected\" }} ]}}"}, | |
| {"role": "user", "content": f"Translate these to {target_lang}:\n{articles_text}"} | |
| ], | |
| temperature=0.1, | |
| timeout=60 # High timeout for batch stability | |
| ) | |
| raw_result = self._clean_json(response.choices[0].message.content.strip()) | |
| if raw_result and raw_result.get("translated"): | |
| logger.info(f"Successfully translated {len(raw_result.get('translated'))} items to {target_lang}") | |
| return raw_result.get("translated") | |
| else: | |
| logger.warning(f"Translation JSON parse failed for {target_lang}. Response was not in expected format.") | |
| # Treat JSON parse error as a minor error, trigger rotate | |
| self._mark_key_limited(key, is_dead=False) | |
| except Exception as e: | |
| error_msg = str(e).lower() | |
| is_rate = any(word in error_msg for word in ["429", "rate limit", "rate_limit", "throttle", "too many requests"]) and "insufficient_quota" not in error_msg and "quota exceeded" not in error_msg | |
| is_quota = not is_rate and any(word in error_msg for word in ["quota", "insufficient", "spend", "invalid", "deactivated", "disabled", "revoked", "billing"]) | |
| self._mark_key_limited(key, is_dead=is_quota) | |
| logger.warning(f"Batch translation failed on key {key[:8]}... (attempt {attempt+1}/{max_attempts}): {e}. Rotating...") | |
| # Rotate key for the next attempt | |
| key, k_idx = self._get_best_key() | |
| if not key: | |
| break | |
| client, provider = self._get_client_by_key(key) | |
| return await asyncio.gather(*[translate_item_safe(item) for item in batch_items]) | |
| batch_results = await asyncio.gather(*[translate_batch(b, i) for i, b in enumerate(batches)]) | |
| all_translated = [tr for res in batch_results for tr in res] | |
| def _save_cache_sync(): | |
| db = SessionLocal() | |
| try: | |
| trans_map = {str(tr.get("id")): tr for tr in all_translated} | |
| for idx in untranslated_indices: | |
| orig = stories[idx] | |
| tr = trans_map.get(str(orig.get("id"))) | |
| if not tr: continue | |
| cached_title = tr.get("t") | |
| cached_bullets = tr.get("b") | |
| cached_why = tr.get("w") | |
| cached_affected = tr.get("a") | |
| orig.update({ | |
| "title": cached_title, "headline": cached_title, "bullets": cached_bullets, "summary_bullets": cached_bullets, | |
| "why": cached_why, "why_it_matters": cached_why, "summary": cached_why, | |
| "affected": cached_affected, "who_is_affected": cached_affected, | |
| "is_translated": True | |
| }) | |
| article = db.query(VerifiedNews).filter(VerifiedNews.id == int(orig["id"])).first() | |
| if article: | |
| cache = article.translation_cache or {} | |
| cache[target_lang] = { | |
| "title": cached_title, "bullets": cached_bullets, | |
| "why": cached_why, "affected": cached_affected | |
| } | |
| article.translation_cache = cache | |
| db.commit() | |
| # 2. Save External Cache | |
| if str(orig.get("id")) == "0": | |
| url = orig.get("url") | |
| if url: | |
| cache_key = f"{url}_{target_lang}" | |
| self._external_cache[cache_key] = { | |
| "t": tr.get("t"), "b": tr.get("b"), | |
| "w": tr.get("w"), "a": tr.get("a") | |
| } | |
| self._save_external_cache() | |
| finally: | |
| db.close() | |
| await asyncio.to_thread(_save_cache_sync) | |
| return node_data | |
| except Exception as e: | |
| logger.error(f"Bulk translation failed: {e}") | |
| return node_data | |
| async def _do_translate(self, items: List[Dict[str, str]], target_lang: str, node_title: str = "") -> Dict[str, Any]: | |
| if not items or not target_lang or target_lang.lower() == 'english': | |
| return {"translated_stories": items, "node_title": node_title} | |
| try: | |
| translated = await self.translate_stories(items, target_lang) | |
| trans_title = await self.translate_text(node_title, target_lang) if node_title else node_title | |
| return {"translated_stories": translated, "node_title": trans_title} | |
| except Exception as e: | |
| return {"translated_stories": items, "node_title": node_title} | |