Created
May 14, 2026 06:29
-
-
Save vpnry/c30adb76abb77c3858352ee2c68b9c63 to your computer and use it in GitHub Desktop.
Translate MM Tipitaka translation into English using Deepseek v4 flash via NIM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Tipitaka Myanmar → English Translation Pipeline | |
| Uses DeepSeek via NVIDIA NIM API to translate HTML files. | |
| Usage: | |
| export NVIDIA_API_KEY="your_api_key_here" | |
| python translate.py # translate all files | |
| python translate.py --file 01_vinaya_01 # translate one file | |
| python translate.py --resume # resume from last checkpoint | |
| python translate.py --status # show progress status | |
| On VPS: | |
| nohup python translate.py > nohup.log 2>&1 & | |
| Monitor it later (when you log back in) | |
| # See if it's still running | |
| ps aux | grep translate.py | |
| # Watch live output | |
| tail -f nohup.log | |
| # Or check the translation log | |
| tail -f translation.log | |
| # Check progress status | |
| python translate.py --status | |
| Stop it if needed | |
| # Find the process ID | |
| ps aux | grep translate.py | |
| # Kill it (replace 12345 with actual PID) | |
| kill 12345 | |
| """ | |
| import os | |
| import re | |
| import json | |
| import time | |
| import argparse | |
| import logging | |
| from pathlib import Path | |
| from copy import copy | |
| from typing import Optional | |
| from openai import OpenAI | |
| from bs4 import BeautifulSoup, NavigableString, Tag | |
| # ─── Configuration ──────────────────────────────────────────────────────────── | |
| BASE_DIR = Path(__file__).parent | |
| INPUT_DIR = BASE_DIR / "assets" / "books" | |
| OUTPUT_DIR = BASE_DIR / "assets" / "books_en" | |
| PROGRESS_FILE = BASE_DIR / "translation_progress.json" | |
| LOG_FILE = BASE_DIR / "translation.log" | |
| NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "") | |
| MODEL = "deepseek-ai/deepseek-v4-flash" | |
| # How many HTML text nodes to batch into one API call (balance cost vs speed) | |
| BATCH_SIZE = 20 # number of paragraphs per API request | |
| MAX_TOKENS = 16384 | |
| TEMPERATURE = 1 | |
| TOP_P = 0.95 | |
| # Retry settings | |
| MAX_RETRIES = 5 | |
| RETRY_DELAY = 10 # seconds between retries | |
| RATE_LIMIT_DELAY = 2 # seconds between API calls | |
| # Tags whose text content will be translated | |
| TRANSLATABLE_TAGS = {"p", "h1", "h2", "h3", "h4", "title", "span"} | |
| # ─── Logging ────────────────────────────────────────────────────────────────── | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(message)s", | |
| handlers=[ | |
| logging.FileHandler(LOG_FILE, encoding="utf-8"), | |
| logging.StreamHandler(), | |
| ], | |
| ) | |
| log = logging.getLogger(__name__) | |
| # ─── NVIDIA / DeepSeek Client ───────────────────────────────────────────────── | |
| def make_client() -> OpenAI: | |
| if not NVIDIA_API_KEY: | |
| raise ValueError( | |
| "NVIDIA_API_KEY environment variable is not set.\n" | |
| "Run: export NVIDIA_API_KEY='your_key_here'" | |
| ) | |
| return OpenAI( | |
| base_url="https://integrate.api.nvidia.com/v1", | |
| api_key=NVIDIA_API_KEY, | |
| ) | |
| # ─── Progress Tracking ──────────────────────────────────────────────────────── | |
| def load_progress() -> dict: | |
| if PROGRESS_FILE.exists(): | |
| with open(PROGRESS_FILE, "r", encoding="utf-8") as f: | |
| return json.load(f) | |
| return {} | |
| def save_progress(progress: dict): | |
| with open(PROGRESS_FILE, "w", encoding="utf-8") as f: | |
| json.dump(progress, f, indent=2, ensure_ascii=False) | |
| # ─── Translation ────────────────────────────────────────────────────────────── | |
| SYSTEM_PROMPT = """You are an expert translator specializing in Theravada Buddhist texts. | |
| Translate the following Myanmar (Burmese) Buddhist scripture into clear, accurate English. | |
| Rules: | |
| - Preserve Pali technical terms (e.g., Dhamma, Sangha, Nibbana, Bhikkhu, sutta names) — keep them as-is or transliterate | |
| - Keep paragraph numbers like ၁, ၂, ၃ etc. — translate them to 1, 2, 3 | |
| - Preserve the meaning and reverent tone of the original | |
| - Do NOT add explanations or commentary — translate only | |
| - Return ONLY the translations, one per line, in the same order as input | |
| - Use |||SEPARATOR||| between each translated item | |
| Input format: numbered items separated by |||SEPARATOR||| | |
| Output format: same numbered translations separated by |||SEPARATOR|||""" | |
| def translate_batch(client: OpenAI, texts: list[str]) -> list[str]: | |
| """Translate a batch of text strings using DeepSeek. Returns list of translations.""" | |
| if not texts: | |
| return [] | |
| # Build numbered input | |
| numbered_input = "|||SEPARATOR|||".join( | |
| f"[{i+1}] {t}" for i, t in enumerate(texts) | |
| ) | |
| prompt = ( | |
| f"Translate each of the following {len(texts)} Myanmar Buddhist text segments " | |
| f"to English. Return them separated by |||SEPARATOR||| in the same order:\n\n" | |
| f"{numbered_input}" | |
| ) | |
| for attempt in range(1, MAX_RETRIES + 1): | |
| try: | |
| collected = [] | |
| completion = client.chat.completions.create( | |
| model=MODEL, | |
| messages=[ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| temperature=TEMPERATURE, | |
| top_p=TOP_P, | |
| max_tokens=MAX_TOKENS, | |
| extra_body={"chat_template_kwargs": {"thinking": False}}, | |
| stream=True, | |
| ) | |
| for chunk in completion: | |
| if not getattr(chunk, "choices", None): | |
| continue | |
| delta = chunk.choices[0].delta | |
| if delta and delta.content: | |
| collected.append(delta.content) | |
| full_response = "".join(collected).strip() | |
| translations = _parse_batch_response(full_response, len(texts)) | |
| if len(translations) == len(texts): | |
| time.sleep(RATE_LIMIT_DELAY) | |
| return translations | |
| log.warning( | |
| f"Got {len(translations)} translations for {len(texts)} inputs " | |
| f"(attempt {attempt}). Retrying..." | |
| ) | |
| except Exception as e: | |
| log.error(f"API error on attempt {attempt}/{MAX_RETRIES}: {e}") | |
| if attempt < MAX_RETRIES: | |
| log.info(f"Waiting {RETRY_DELAY}s before retry...") | |
| time.sleep(RETRY_DELAY * attempt) | |
| else: | |
| log.error("Max retries reached. Returning originals as fallback.") | |
| return texts # fallback: keep original text | |
| return texts # fallback | |
| def _parse_batch_response(response: str, expected: int) -> list[str]: | |
| """Parse the |||SEPARATOR|||-delimited response into a list.""" | |
| parts = response.split("|||SEPARATOR|||") | |
| cleaned = [] | |
| for part in parts: | |
| part = part.strip() | |
| # Remove leading [N] numbering if model echoed it | |
| part = re.sub(r"^\[\d+\]\s*", "", part).strip() | |
| if part: | |
| cleaned.append(part) | |
| return cleaned | |
| # ─── HTML Processing ────────────────────────────────────────────────────────── | |
| def collect_text_nodes(soup: BeautifulSoup) -> list[tuple[Tag, str]]: | |
| """ | |
| Walk the soup tree and collect (tag, original_text) for all | |
| translatable leaf-text elements that contain Myanmar characters. | |
| """ | |
| nodes = [] | |
| myanmar_pattern = re.compile(r"[\u1000-\u109F\uA9E0-\uA9FF\uAA60-\uAA7F]") | |
| for tag in soup.find_all(TRANSLATABLE_TAGS): | |
| # Only direct text (not nested tags) to avoid double-processing | |
| direct_text = "".join( | |
| str(c) for c in tag.children if isinstance(c, NavigableString) | |
| ).strip() | |
| if direct_text and myanmar_pattern.search(direct_text): | |
| nodes.append((tag, direct_text)) | |
| return nodes | |
| def apply_translations(nodes: list[tuple[Tag, str]], translations: list[str]): | |
| """Replace original text in-place within each tag.""" | |
| for (tag, original), translated in zip(nodes, translations): | |
| # Replace NavigableString children with translated text, | |
| # preserving any child tags (e.g., <span class="paragraph">) | |
| new_strings = [] | |
| for child in list(tag.children): | |
| if isinstance(child, NavigableString): | |
| new_strings.append((child, translated)) | |
| break # replace only the first/main text node | |
| for string_node, new_text in new_strings: | |
| string_node.replace_with(new_text) | |
| def translate_html_file( | |
| client: OpenAI, | |
| input_path: Path, | |
| output_path: Path, | |
| progress: dict, | |
| file_key: str, | |
| ) -> bool: | |
| """ | |
| Translate a single HTML file. Returns True on success. | |
| Supports resuming from last completed batch. | |
| """ | |
| log.info(f"Processing: {input_path.name}") | |
| with open(input_path, "r", encoding="utf-8") as f: | |
| soup = BeautifulSoup(f.read(), "html.parser") | |
| nodes = collect_text_nodes(soup) | |
| total = len(nodes) | |
| log.info(f" Found {total} translatable text nodes") | |
| if total == 0: | |
| log.info(" No Myanmar text found, copying as-is.") | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| with open(output_path, "w", encoding="utf-8") as f: | |
| f.write(str(soup)) | |
| return True | |
| # Resume from checkpoint | |
| file_progress = progress.get(file_key, {"completed_batches": 0, "translations": {}}) | |
| completed_batches = file_progress.get("completed_batches", 0) | |
| cached_translations: dict[int, str] = { | |
| int(k): v for k, v in file_progress.get("translations", {}).items() | |
| } | |
| # Build batches | |
| batches: list[list[tuple[int, str]]] = [] # [(node_index, text), ...] | |
| for i in range(0, total, BATCH_SIZE): | |
| batch = [(j, nodes[j][1]) for j in range(i, min(i + BATCH_SIZE, total))] | |
| batches.append(batch) | |
| log.info(f" {len(batches)} batches total, {completed_batches} already done") | |
| # Translate remaining batches | |
| for batch_idx, batch in enumerate(batches): | |
| if batch_idx < completed_batches: | |
| continue # already translated | |
| indices = [item[0] for item in batch] | |
| texts = [item[1] for item in batch] | |
| log.info(f" Batch {batch_idx+1}/{len(batches)} ({len(texts)} items)...") | |
| translated = translate_batch(client, texts) | |
| # Cache results | |
| for idx, trans in zip(indices, translated): | |
| cached_translations[idx] = trans | |
| # Save checkpoint after every batch | |
| file_progress["completed_batches"] = batch_idx + 1 | |
| file_progress["translations"] = {str(k): v for k, v in cached_translations.items()} | |
| progress[file_key] = file_progress | |
| save_progress(progress) | |
| log.info(f" ✓ Batch {batch_idx+1} done") | |
| # Apply all translations to soup | |
| all_translations = [cached_translations.get(i, nodes[i][1]) for i in range(total)] | |
| apply_translations(nodes, all_translations) | |
| # Write output | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| with open(output_path, "w", encoding="utf-8") as f: | |
| f.write(str(soup)) | |
| log.info(f" ✓ Saved to: {output_path}") | |
| # Mark file as fully complete | |
| file_progress["done"] = True | |
| progress[file_key] = file_progress | |
| save_progress(progress) | |
| return True | |
| # ─── CLI ────────────────────────────────────────────────────────────────────── | |
| def show_status(progress: dict): | |
| """Print a status table of all files.""" | |
| all_files = sorted(INPUT_DIR.glob("*.html")) | |
| print(f"\n{'File':<35} {'Status':<12} {'Batches Done'}") | |
| print("-" * 65) | |
| for f in all_files: | |
| key = f.stem | |
| p = progress.get(key, {}) | |
| done = p.get("done", False) | |
| batches = p.get("completed_batches", 0) | |
| status = "✅ done" if done else ("🔄 partial" if batches > 0 else "⏳ pending") | |
| print(f"{f.name:<35} {status:<12} {batches}") | |
| print() | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Translate Myanmar Buddhist HTML to English") | |
| parser.add_argument("--file", help="Translate a single file (stem name, e.g. 01_vinaya_01)") | |
| parser.add_argument("--resume", action="store_true", help="Resume from last checkpoint") | |
| parser.add_argument("--status", action="store_true", help="Show translation progress") | |
| parser.add_argument("--reset", help="Reset progress for a specific file stem") | |
| args = parser.parse_args() | |
| progress = load_progress() | |
| if args.status: | |
| show_status(progress) | |
| return | |
| if args.reset: | |
| if args.reset in progress: | |
| del progress[args.reset] | |
| save_progress(progress) | |
| log.info(f"Progress reset for: {args.reset}") | |
| else: | |
| log.info(f"No progress found for: {args.reset}") | |
| return | |
| client = make_client() | |
| OUTPUT_DIR.mkdir(parents=True, exist_ok=True) | |
| if args.file: | |
| # Single file mode | |
| input_path = INPUT_DIR / f"{args.file}.html" | |
| output_path = OUTPUT_DIR / f"{args.file}.html" | |
| if not input_path.exists(): | |
| log.error(f"File not found: {input_path}") | |
| return | |
| file_key = args.file | |
| if progress.get(file_key, {}).get("done") and not args.resume: | |
| log.info(f"{args.file} is already fully translated. Use --resume to redo.") | |
| return | |
| translate_html_file(client, input_path, output_path, progress, file_key) | |
| else: | |
| # All files mode | |
| all_files = sorted(INPUT_DIR.glob("*.html")) | |
| log.info(f"Found {len(all_files)} HTML files in {INPUT_DIR}") | |
| success = 0 | |
| skipped = 0 | |
| failed = 0 | |
| for input_path in all_files: | |
| file_key = input_path.stem | |
| output_path = OUTPUT_DIR / input_path.name | |
| # Skip already completed files (unless --resume) | |
| if progress.get(file_key, {}).get("done") and not args.resume: | |
| log.info(f"Skipping {input_path.name} (already done)") | |
| skipped += 1 | |
| continue | |
| try: | |
| ok = translate_html_file(client, input_path, output_path, progress, file_key) | |
| if ok: | |
| success += 1 | |
| else: | |
| failed += 1 | |
| except KeyboardInterrupt: | |
| log.info("\n⚠️ Interrupted. Progress saved. Run again to resume.") | |
| break | |
| except Exception as e: | |
| log.error(f"Failed to process {input_path.name}: {e}") | |
| failed += 1 | |
| continue | |
| log.info(f"\n{'='*50}") | |
| log.info(f"Done! ✅ {success} translated | ⏭ {skipped} skipped | ❌ {failed} failed") | |
| log.info(f"Output directory: {OUTPUT_DIR}") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment