Download app.py from CVNSS/dich: direct link, hf CLI and curl.
- Browser
- Download file 11.9 kB
-
https://huggingface.co/spaces/CVNSS/dich/resolve/main/app.py
- Command line
-
hf download hf://spaces/CVNSS/dich/app.py
-
curl -L -o app.py https://huggingface.co/spaces/CVNSS/dich/resolve/main/app.py
11.9 kB
| import asyncio | |
| import hashlib | |
| import os | |
| import re | |
| import time | |
| from collections import OrderedDict | |
| from typing import Any, Optional | |
| import httpx | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel, Field | |
| APP_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| STATIC_DIR = os.path.join(APP_DIR, "static") | |
| API_BASE = os.getenv("TRANSLATE_API_BASE", "https://dichmay.itrithuc.vn:1721").rstrip("/") | |
| VERIFY_SSL = os.getenv("TRANSLATE_VERIFY_SSL", "true").lower() not in {"0", "false", "no"} | |
| MAX_CHARS = int(os.getenv("MAX_CHARS", "12000")) | |
| CACHE_TTL_SEC = int(os.getenv("CACHE_TTL_SEC", str(24 * 3600))) | |
| CACHE_MAX_ITEMS = int(os.getenv("CACHE_MAX_ITEMS", "4096")) | |
| TRANSLATE_TIMEOUT_SEC = float(os.getenv("TRANSLATE_TIMEOUT_SEC", "65")) | |
| MAX_UPSTREAM_CONCURRENCY = int(os.getenv("MAX_UPSTREAM_CONCURRENCY", "2")) | |
| POLL_MIN_DELAY_SEC = float(os.getenv("POLL_MIN_DELAY_SEC", "0.35")) | |
| POLL_MAX_DELAY_SEC = float(os.getenv("POLL_MAX_DELAY_SEC", "4.0")) | |
| QUEUE_WAIT_RE = re.compile(r"(?:ước\s*tính|uoc\s*tinh|estimated|wait|chờ|cho)[^0-9]{0,24}(\d{1,4})\s*(?:s|sec|second|giây|giay)?", re.IGNORECASE) | |
| LANGS = {"vi", "en", "zh", "lo", "km"} | |
| class TTLCache: | |
| def __init__(self, max_items: int = 4096, ttl: int = 86400) -> None: | |
| self.max_items = max_items | |
| self.ttl = ttl | |
| self._data: OrderedDict[str, tuple[float, str]] = OrderedDict() | |
| self._lock = asyncio.Lock() | |
| async def get(self, key: str) -> Optional[str]: | |
| now = time.time() | |
| async with self._lock: | |
| hit = self._data.get(key) | |
| if not hit: | |
| return None | |
| ts, value = hit | |
| if now - ts > self.ttl: | |
| self._data.pop(key, None) | |
| return None | |
| self._data.move_to_end(key) | |
| return value | |
| async def set(self, key: str, value: str) -> None: | |
| async with self._lock: | |
| self._data[key] = (time.time(), value) | |
| self._data.move_to_end(key) | |
| while len(self._data) > self.max_items: | |
| self._data.popitem(last=False) | |
| async def size(self) -> int: | |
| async with self._lock: | |
| return len(self._data) | |
| cache = TTLCache(max_items=CACHE_MAX_ITEMS, ttl=CACHE_TTL_SEC) | |
| inflight: dict[str, asyncio.Future[str]] = {} | |
| inflight_lock = asyncio.Lock() | |
| upstream_semaphore = asyncio.Semaphore(MAX_UPSTREAM_CONCURRENCY) | |
| app = FastAPI(title="Bila Translator Realtime Space", version="4.0.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=False, | |
| allow_methods=["GET", "POST", "OPTIONS"], | |
| allow_headers=["*"], | |
| ) | |
| app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") | |
| class TranslateRequest(BaseModel): | |
| sourceText: str = Field(..., min_length=0, max_length=MAX_CHARS) | |
| sourceLang: str = Field(..., min_length=2, max_length=8) | |
| targetLang: str = Field(..., min_length=2, max_length=8) | |
| forceRefresh: bool = False | |
| class TranslateResponse(BaseModel): | |
| ok: bool | |
| sourceLang: str | |
| targetLang: str | |
| text: str | |
| cached: bool = False | |
| ms: int | |
| error: Optional[str] = None | |
| async def index() -> FileResponse: | |
| return FileResponse(os.path.join(STATIC_DIR, "index.html")) | |
| async def health() -> dict[str, Any]: | |
| return { | |
| "ok": True, | |
| "apiBase": API_BASE, | |
| "cacheItems": await cache.size(), | |
| "maxChars": MAX_CHARS, | |
| "maxUpstreamConcurrency": MAX_UPSTREAM_CONCURRENCY, | |
| "translateTimeoutSec": TRANSLATE_TIMEOUT_SEC, | |
| } | |
| async def config() -> dict[str, Any]: | |
| return { | |
| "langs": ["vi", "cvnss", "en", "zh", "lo", "km"], | |
| "maxChars": MAX_CHARS, | |
| "cacheTtlSec": CACHE_TTL_SEC, | |
| } | |
| def normalize_lang(lang: str) -> str: | |
| lang = (lang or "").strip().lower() | |
| aliases = {"cn": "zh", "zh-cn": "zh", "zh_hans": "zh", "lao": "lo", "kh": "km", "vi-vn": "vi"} | |
| return aliases.get(lang, lang) | |
| def cache_key(src_lang: str, target_lang: str, text: str) -> str: | |
| digest = hashlib.sha256(text.encode("utf-8")).hexdigest() | |
| return f"v4|{src_lang}|{target_lang}|{digest}" | |
| async def fetch_json(client: httpx.AsyncClient, method: str, url: str, **kwargs: Any) -> Any: | |
| response = await client.request(method, url, **kwargs) | |
| response.raise_for_status() | |
| if not response.content: | |
| return {} | |
| return response.json() | |
| def _deep_values(obj: Any) -> list[Any]: | |
| values: list[Any] = [] | |
| if isinstance(obj, dict): | |
| for value in obj.values(): | |
| values.append(value) | |
| values.extend(_deep_values(value)) | |
| elif isinstance(obj, list): | |
| for value in obj: | |
| values.append(value) | |
| values.extend(_deep_values(value)) | |
| return values | |
| def extract_queue_wait_seconds(payload: Any) -> Optional[float]: | |
| """Read common upstream queue hints without depending on one exact schema.""" | |
| if not isinstance(payload, dict): | |
| return None | |
| direct_names = { | |
| "estimatedWait", "estimated_wait", "estimatedWaitSec", "estimated_wait_sec", | |
| "estimatedWaitingSeconds", "waitingSeconds", "waitSeconds", "queueWait", | |
| "queue_wait", "queueWaitSec", "queue_wait_sec", "eta", "etaSec", "eta_sec", | |
| } | |
| for name in direct_names: | |
| value = payload.get(name) | |
| if isinstance(value, (int, float)) and value > 0: | |
| return float(value) | |
| if isinstance(value, str) and value.strip().isdigit(): | |
| return float(value.strip()) | |
| for value in _deep_values(payload): | |
| if isinstance(value, str): | |
| m = QUEUE_WAIT_RE.search(value) | |
| if m: | |
| return float(m.group(1)) | |
| return None | |
| async def poll_until_done(client: httpx.AsyncClient, history_id: str, task_id: str) -> str: | |
| # Queue-aware polling: do not hammer the upstream queue. When the upstream | |
| # reports an estimated wait, sleep close to that hint before polling again. | |
| started = time.monotonic() | |
| delay = POLL_MIN_DELAY_SEC | |
| while time.monotonic() - started <= TRANSLATE_TIMEOUT_SEC: | |
| await asyncio.sleep(delay) | |
| url = f"{API_BASE}/translation-history/get-single" | |
| data = await fetch_json( | |
| client, | |
| "GET", | |
| url, | |
| params={"translationHistoryId": history_id, "taskId": task_id, "_t": int(time.time() * 1000)}, | |
| ) | |
| payload = data.get("data", data) if isinstance(data, dict) else {} | |
| status = str(payload.get("status", "")).lower() | |
| result_url = payload.get("resultUrl") or payload.get("result_url") | |
| if status == "translated" and result_url: | |
| return str(result_url) | |
| if status in {"failed", "error"}: | |
| raise RuntimeError("Upstream translation failed") | |
| queue_wait = extract_queue_wait_seconds(payload) | |
| if queue_wait: | |
| delay = max(POLL_MIN_DELAY_SEC, min(float(queue_wait) * 0.85, POLL_MAX_DELAY_SEC)) | |
| else: | |
| delay = min(delay * 1.55, POLL_MAX_DELAY_SEC) | |
| raise TimeoutError("Translation timeout") | |
| def extract_text(payload: Any) -> str: | |
| if not isinstance(payload, dict): | |
| return "" | |
| candidates = [ | |
| payload.get("target_text"), | |
| payload.get("targetText"), | |
| payload.get("text"), | |
| ] | |
| data = payload.get("data") | |
| if isinstance(data, dict): | |
| candidates.extend([data.get("target_text"), data.get("targetText"), data.get("text")]) | |
| for item in candidates: | |
| if isinstance(item, str) and item.strip(): | |
| return item.strip() | |
| return "" | |
| async def translate_upstream(text: str, source_lang: str, target_lang: str) -> str: | |
| # Protect the upstream translation queue. Browser aborts do not necessarily | |
| # cancel a task that was already created upstream, so the Space must limit | |
| # how many fresh upstream jobs it creates at the same time. | |
| async with upstream_semaphore: | |
| timeout = httpx.Timeout(connect=8, read=max(TRANSLATE_TIMEOUT_SEC + 10, 75), write=8, pool=8) | |
| async with httpx.AsyncClient(timeout=timeout, verify=VERIFY_SSL, follow_redirects=True) as client: | |
| init = await fetch_json( | |
| client, | |
| "POST", | |
| f"{API_BASE}/translate", | |
| json={"sourceText": text, "sourceLang": source_lang, "targetLang": target_lang}, | |
| headers={"Content-Type": "application/json"}, | |
| ) | |
| payload = init.get("data", init) if isinstance(init, dict) else {} | |
| task_id = payload.get("taskId") | |
| history_id = payload.get("translationHistoryId") or payload.get("translationHitoryId") | |
| if not task_id or not history_id: | |
| raise RuntimeError("Upstream did not return taskId/historyId") | |
| result_url = await poll_until_done(client, str(history_id), str(task_id)) | |
| result_url = str(result_url).lstrip("/") | |
| final = await fetch_json(client, "GET", f"{API_BASE}/{result_url}", params={"_t": int(time.time() * 1000)}) | |
| translated = extract_text(final) | |
| if not translated: | |
| raise RuntimeError("Upstream returned empty translation") | |
| return translated | |
| async def translate_dedupe(text: str, source_lang: str, target_lang: str, key: str) -> str: | |
| async with inflight_lock: | |
| fut = inflight.get(key) | |
| if fut is None: | |
| loop = asyncio.get_running_loop() | |
| fut = loop.create_task(translate_upstream(text, source_lang, target_lang)) | |
| inflight[key] = fut | |
| try: | |
| return await fut | |
| finally: | |
| async with inflight_lock: | |
| if inflight.get(key) is fut: | |
| inflight.pop(key, None) | |
| async def translate(req: TranslateRequest) -> TranslateResponse: | |
| t0 = time.perf_counter() | |
| source_lang = normalize_lang(req.sourceLang) | |
| target_lang = normalize_lang(req.targetLang) | |
| text = req.sourceText.strip() | |
| if not text or source_lang == target_lang: | |
| return TranslateResponse(ok=True, sourceLang=source_lang, targetLang=target_lang, text=text, cached=True, ms=0) | |
| if source_lang == "cvnss" or target_lang == "cvnss": | |
| raise HTTPException(status_code=400, detail="CVNSS is converted in the browser for offline-speed sync") | |
| if source_lang not in LANGS or target_lang not in LANGS: | |
| raise HTTPException(status_code=400, detail=f"Unsupported language pair: {source_lang}->{target_lang}") | |
| key = cache_key(source_lang, target_lang, text) | |
| if not req.forceRefresh: | |
| hit = await cache.get(key) | |
| if hit is not None: | |
| return TranslateResponse( | |
| ok=True, | |
| sourceLang=source_lang, | |
| targetLang=target_lang, | |
| text=hit, | |
| cached=True, | |
| ms=int((time.perf_counter() - t0) * 1000), | |
| ) | |
| try: | |
| translated = await translate_dedupe(text, source_lang, target_lang, key) | |
| await cache.set(key, translated) | |
| return TranslateResponse( | |
| ok=True, | |
| sourceLang=source_lang, | |
| targetLang=target_lang, | |
| text=translated, | |
| cached=False, | |
| ms=int((time.perf_counter() - t0) * 1000), | |
| ) | |
| except Exception as exc: | |
| return JSONResponse( | |
| status_code=502, | |
| content={ | |
| "ok": False, | |
| "sourceLang": source_lang, | |
| "targetLang": target_lang, | |
| "text": "", | |
| "cached": False, | |
| "ms": int((time.perf_counter() - t0) * 1000), | |
| "error": str(exc), | |
| }, | |
| ) | |