"""Gradio UI for HachimiMT zh→vi translation.""" from __future__ import annotations import atexit import hashlib import html import os import platform import tempfile import time import unicodedata import uuid from collections.abc import Iterator from datetime import datetime from pathlib import Path os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") def _patch_windows_platform() -> None: """Avoid Python 3.13 WMI calls during pandas/Gradio import on Windows.""" if os.name != "nt": return platform.system = lambda: "Windows" raw_machine = ( os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE") ) if not raw_machine: return aliases = { "amd64": "AMD64", "x86_64": "AMD64", "arm64": "ARM64", "aarch64": "ARM64", "x86": "x86", "i386": "x86", } machine = aliases.get(raw_machine.strip().lower(), raw_machine.strip()) if machine: platform.machine = lambda: machine _patch_windows_platform() import gradio as gr from glossary import ( PLACEHOLDER_SCOPE_DOCUMENT, PLACEHOLDER_SCOPE_LINE, GlossaryValidationError, apply_glossary_rows, compile_glossary, glossary_table_rows, protect_glossary_text, read_glossary_file, restore_glossary_rows, write_glossary_file, ) import hardware from hardware import detect_hardware_profile from progress_tracker import finish_progress, reset_progress, set_progress, snapshot from text_preprocess import ( NORMALIZE_AUTO, NORMALIZE_NONE, NORMALIZE_T2S, normalization_message, normalize_chinese_text, ) from honorific_normalize import normalize_honorifics from postprocess_policy import classify_genre, v9_route_for_decision from pronoun_harmonizer_v9 import harmonize_pronouns_v9 from translator import ( DEFAULT_MODEL_KEY, MODELS, Backend, HachimiTranslator, ensure_model_files, is_model_downloaded, ) def _env_float(name: str, default: float, *, min_value: float = 0.0, max_value: float = 60.0) -> float: raw = os.environ.get(name, "").strip() if not raw: return default try: return max(min_value, min(max_value, float(raw))) except ValueError: return default ROOT = Path(__file__).resolve().parent.parent EXPORTS_DIR = ROOT / "exports" PID_FILE = ROOT / ".hachimimt.pid" APP_PORT = 7860 EXPORT_FILE_PREFIX = "hachimi_export_" SPACE_EXPORT_MAX_AGE_SECONDS = 24 * 60 * 60 # HF Space tự set SPACE_ID. Khi ở Space: CPU-only (ẩn nút cài GPU/torch), process # do HF quản (bỏ PID file), HF tự lo host/port (không ép 127.0.0.1). App chạy # local KHÔNG đổi gì. IS_HF_SPACE = bool(os.environ.get("SPACE_ID") or os.environ.get("SPACE_HOST")) # ⚠️ cpu-basic Space: os.cpu_count() báo số core MÁY CHỦ vật lý (16-32) chứ không # phải 2 vCPU thực được cấp → hardware-detect set ct2_threads/batch quá cao → # OVERSUBSCRIPTION, dịch chậm 10-17×. Ép mức an toàn cho cpu-basic (giống Space # demo cũ: 2 threads) TRƯỚC khi detect_hardware_profile() chạy. User vẫn override # được qua env nếu chạy Space tier mạnh hơn. if IS_HF_SPACE: # "Space fast mode" cho cpu-basic (đo bởi Codex: kéo 16 chunk 17.8s → ~Space cũ). # os.cpu_count() báo core MÁY CHỦ (16-32) ≠ 2 vCPU thực → tránh oversubscription # + dùng cấu hình batch hợp CPU nhỏ (examples/window=1, như Space demo cũ). Set # TRƯỚC detect_hardware_profile(). KHÔNG ảnh hưởng bản local (gate IS_HF_SPACE). os.environ.setdefault("HACHIMIMT_THREADS", "2") os.environ.setdefault("HACHIMIMT_TOKENIZE_WORKERS", "2") os.environ.setdefault("HACHIMIMT_BATCH_SIZE", "8") os.environ.setdefault("HACHIMIMT_INTER_THREADS", "1") os.environ.setdefault("HACHIMIMT_CT2_BATCH_TYPE", "examples") os.environ.setdefault("HACHIMIMT_CT2_WINDOW_MULTIPLIER", "1") os.environ.setdefault("HACHIMIMT_PROGRESS_SECONDS", "1.0") def _env_truthy(name: str) -> bool: """CHỈ "1"/"true"/"yes" (case-insensitive) mới bật → tránh env rác bật nhầm.""" return os.environ.get(name, "").strip().lower() in {"1", "true", "yes"} # Panel feedback/sửa câu là DEV-ONLY: chỉ người phát triển (cải thiện model) cần. # Bản zip share cho người đọc truyện KHÔNG đặt biến này → không thấy panel. DEV_FEEDBACK = _env_truthy("HACHIMIMT_DEV_FEEDBACK") def feedback_panel_enabled(dev_flag: bool, is_space: bool) -> bool: """Panel chỉ hiện cho dev local: bật cờ DEV_FEEDBACK và KHÔNG ở HF Space (Space filesystem ephemeral → ghi sẽ mất, hiện panel là trải nghiệm dối).""" return dev_flag and not is_space def _feedback_honorific_label(kinship: bool, pronouns: bool) -> str: """Nhãn cố định cho metadata feedback (không build ad-hoc trong handler).""" if kinship and pronouns: return "kinship+pronoun" if kinship: return "kinship" if pronouns: return "pronoun" return "none" # Package bản chạy-máy (GPU/offline/file lớn) — .zip đặt NGAY trong Space repo # (public, không cần đăng nhập; repo GitHub đang private nên không link tới đó). # Banner + hướng dẫn cài CHỈ hiện trên Space (local thì đang chạy local rồi → thừa). LOCAL_ZIP_URL = "https://huggingface.co/spaces/ngocdang83/HachimiMT-demo/resolve/main/hachimimt-local.zip" # Notebook Colab đặt ở repo GitHub PUBLIC riêng (Colab chỉ import được từ GitHub/ # Drive, KHÔNG từ HF Space URL — đã test). Chạy app trên CPU/GPU free của Google. COLAB_URL = "https://colab.research.google.com/github/ngocdang8311/hachimimt-colab/blob/master/HachimiMT_Colab.ipynb" # Kaggle import-from-GitHub: kernels/welcome?src= → MỞ Kaggle tạo # notebook từ repo public (như nút Colab). KHÔNG dùng link resolve .ipynb của Space — # cái đó chỉ TẢI file JSON thô (text/plain), không mở Kaggle → gây hiểu nhầm. KAGGLE_NOTEBOOK_URL = "https://www.kaggle.com/kernels/welcome?src=https://github.com/ngocdang8311/hachimimt-colab/blob/master/HachimiMT_Kaggle.ipynb" MAX_TABLE_ROWS = 300 # Số ký tự tối đa hiển thị trong ô "Bản dịch đầy đủ" (file xuất .txt vẫn đầy đủ). FULL_OUTPUT_DISPLAY_LIMIT = 50_000 RESULT_OUTPUT_COUNT = 7 # Trên Space cpu-basic (2 vCPU dùng chung, công khai): cắt văn bản đầu vào để 1 # người tải nguyên bộ truyện không giữ CPU/RAM quá lâu. Bản dài → Colab/cài máy. # Local/Colab KHÔNG giới hạn (gate trong cap_input_for_space). SPACE_MAX_INPUT_CHARS = 150_000 PROGRESS_UPDATE_SECONDS = _env_float("HACHIMIMT_PROGRESS_SECONDS", 0.5, max_value=10.0) TEXT_ENCODINGS = ("utf-8-sig", "utf-8", "gb18030", "gbk", "big5") LEGACY_TEXT_ENCODINGS = ("gb18030", "gbk", "big5") DECODE_SCORE_SAMPLE_CHARS = 200_000 HW_PROFILE = detect_hardware_profile() translator = HachimiTranslator(HW_PROFILE) EXPORTS_DIR.mkdir(exist_ok=True) def gpu_available_but_idle() -> bool: """Máy CÓ GPU NVIDIA vật lý nhưng app đang chạy CPU (thiếu torch-CUDA). Đây là nhóm nên được mời cài torch để bật GPU (nhanh hơn nhiều lần). Trên HF Space: CPU-only, KHÔNG cho pip install runtime → luôn False (ẩn nút cài GPU + khối gpu-hint; mọi chỗ dùng hàm này tự đúng theo). """ if IS_HF_SPACE: return False return bool(hardware.PHYSICAL_NVIDIA_GPU) and not HW_PROFILE.has_cuda def render_gpu_hint_html() -> str: if not gpu_available_but_idle(): return "" gpu = html.escape(hardware.PHYSICAL_GPU_NAME or "GPU NVIDIA") return ( '
' f"⚡ Phát hiện {gpu} nhưng app đang chạy bằng CPU " "(thiếu thư viện CUDA). Bật GPU sẽ dịch nhanh hơn nhiều lần. " "Bấm nút bên dưới để cài tự động (tải ~2–3 GB, cần ~5 GB ổ trống, một lần)." "
" ) # App khoá ở light mode hoàn toàn bằng CSS: khối ":root, .dark" bên dưới # ánh xạ mọi biến theme sang palette "giấy cũ", nên giao diện đúng bất kể # trình duyệt đang ở light hay dark (system-theme-proof). Không dùng JS ép # theme vì Gradio bỏ qua nó ở đây và CSS đã đủ. # Preload font trong để giảm nhấp nháy khi tải. HEAD_HTML = """ """ CUSTOM_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,500&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,500;0,7..72,600;1,7..72,400&family=Noto+Serif+SC:wght@400;600&display=swap'); /* ── Palette "giấy cũ / thư phòng" ─────────────────────────────────── */ :root, .dark { --paper: #f4ecdc; /* nền giấy */ --paper-deep: #e7dac2; /* giấy đậm (đáy gradient) */ --card: #fbf6ea; /* mặt thẻ */ --card-soft: #f7efe0; /* thẻ phụ */ --ink: #221b12; /* mực chính */ --ink-soft: #514537; /* mực nhạt */ --muted: #8a7a64; /* chú thích */ --accent: #b03a26; /* son / chu sa */ --accent-2: #c8553a; /* son sáng */ --gold: #9c7b3f; /* nhũ vàng cũ */ --border: #d8cbb1; /* viền giấy */ --border-soft:#e6dcc8; --shadow: rgba(60, 44, 24, 0.10); /* Ánh xạ vào biến chuẩn của Gradio để mọi widget theo theme này (đây là gốc rễ của bug cũ: dark-mode còn sót làm chữ trắng/nền tối). */ --body-background-fill: transparent; --body-text-color: var(--ink); --body-text-color-subdued: var(--muted); --background-fill-primary: var(--card); --background-fill-secondary: var(--card-soft); --block-background-fill: var(--card); --block-label-background-fill: transparent; --block-border-color: var(--border); --block-label-text-color: var(--ink-soft); --block-title-text-color: var(--ink-soft); --border-color-primary: var(--border); --border-color-accent: var(--accent); --input-background-fill: #fffdf7; --input-background-fill-focus: #fffefb; --input-border-color: var(--border); --input-border-color-focus: var(--accent); --input-placeholder-color: #b6a88f; --neutral-950: var(--ink); --color-accent: var(--accent); --color-accent-soft: #efd9c9; --link-text-color: var(--accent); --table-border-color: var(--border); --table-even-background-fill: var(--card); --table-odd-background-fill: var(--card-soft); --button-secondary-background-fill: #efe6d3; --button-secondary-background-fill-hover: #e7dcc4; --button-secondary-text-color: var(--ink); --button-secondary-border-color: var(--border); /* Toast lỗi: dark-mode Gradio đặt nền #0f0e0d (đen) — ép về giấy. */ --error-background-fill: var(--card); --error-border-color: var(--accent); --error-text-color: var(--accent); --color-red-50: #f7e7e2; } /* ── HARD-OVERRIDE dark mode (HF) ────────────────────────────────────── HF Space đặt class `.dark` trên và định nghĩa lại MỘT SỐ biến nền ở selector sâu hơn (vd --block-background-fill, --background-fill-primary) nên override ở :root thua specificity → card/khối con ra nền nâu-đen, chữ mờ. CSS custom property CÓ hỗ trợ !important và nó thắng mọi định nghĩa thường bất kể specificity. App này CHỈ light → ép cứng các biến nền/text về giấy. */ body.dark, .dark, gradio-app.dark { --background-fill-primary: var(--card) !important; --background-fill-secondary: var(--card-soft) !important; --block-background-fill: var(--card) !important; --block-label-background-fill: transparent !important; --body-background-fill: transparent !important; --body-text-color: var(--ink) !important; --body-text-color-subdued: var(--muted) !important; --block-label-text-color: var(--ink-soft) !important; --block-title-text-color: var(--ink-soft) !important; --input-background-fill: #fffdf7 !important; --panel-background-fill: var(--card) !important; --border-color-primary: var(--border) !important; /* Dataframe có dark overrides riêng cho bảng, nằm ngoài nhóm background chung ở trên. Thiếu ba biến này làm bảng đen dù app đã khoá light mode. */ --table-even-background-fill: var(--card) !important; --table-odd-background-fill: var(--card-soft) !important; --table-border-color: var(--border) !important; /* nhóm neutral Gradio dùng cho nền/scale tối */ --neutral-950: var(--ink) !important; --neutral-900: #2a2014 !important; --neutral-800: #514537 !important; --color-accent-soft: #efd9c9 !important; --error-background-fill: var(--card) !important; color-scheme: light !important; } /* ── Nền & khung tổng thể ──────────────────────────────────────────── */ /* Phủ nền lên html + body + gradio-app: rộng hết khung và ở dark-mode có nền đen — nếu bỏ sót, hai bên (ngoài container 1180px) và vùng overscroll sẽ lộ màu đen. Đây là nguồn gốc các "dải đen" còn lại. */ html { background: #ecdfc9 !important; } html, body, gradio-app, .gradio-container { background: radial-gradient(1200px 600px at 12% -8%, #fbf4e6 0%, transparent 60%), radial-gradient(1000px 700px at 110% 0%, #efe2cb 0%, transparent 55%), linear-gradient(168deg, #f4ecdc 0%, #ecdfc9 55%, #e4d6bd 100%) !important; color: var(--ink) !important; } gradio-app { display: block; min-height: 100vh; } /* Lớp grain giấy rất nhẹ để bớt phẳng */ .gradio-container::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.5; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E"); } .gradio-container > * { position: relative; z-index: 1; } .gradio-container { max-width: 1180px !important; margin: 0 auto !important; } /* ── Typography ────────────────────────────────────────────────────── */ body, .gradio-container, .prose, button, input, textarea, select, .gr-button { font-family: 'Literata', Georgia, 'Times New Roman', serif !important; } h1, h2, h3, h4, .prose h1, .prose h2, .prose h3 { font-family: 'Fraunces', Georgia, serif !important; color: var(--ink) !important; letter-spacing: -0.01em; } /* ── Header (tiêu đề + triện) ──────────────────────────────────────── */ /* Quan trọng: ép overflow visible + bỏ giới hạn cao của khối Markdown, nếu không Gradio sinh scrollbar con ngay cạnh logo (chữ cao hơn khối vài px → overflow:auto). user-select:none để bỏ select nền xanh chướng. */ #app-header, #app-title { overflow: visible !important; max-height: none !important; user-select: none; background: transparent !important; border: none !important; } #app-header { text-align: center; margin: 0.6rem 0 0.2rem; } #app-title h1 { font-size: clamp(2.4rem, 5vw, 3.4rem) !important; font-weight: 600 !important; margin: 0 !important; line-height: 1.18; padding: 0.08em 0; background: linear-gradient(180deg, #2a2014 0%, #6a3a26 120%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; } #app-title h1::after { content: "譯"; /* "Dịch" — con triện đỏ cạnh tiêu đề */ -webkit-text-fill-color: #fff; font-family: 'Noto Serif SC', serif; font-size: 0.42em; font-weight: 600; vertical-align: 0.5em; margin-left: 0.45rem; background: var(--accent); padding: 0.12em 0.18em 0.04em; border-radius: 6px; box-shadow: 0 2px 6px rgba(176,58,38,0.35); } #app-rule { width: 90px; height: 2px; margin: 0.7rem auto 1.3rem; background: linear-gradient(90deg, transparent, var(--gold), transparent); } /* Banner demo (chỉ Space) — mời tải bản local. Khớp palette giấy, viền son nhạt. */ #demo-banner { max-width: 1180px; margin: -0.4rem auto 1.1rem; padding: 10px 18px; background: linear-gradient(180deg, #fbf1e2, #f6ead4); border: 1px solid var(--gold); border-radius: 12px; font-size: 0.92rem; line-height: 1.5; color: var(--ink-soft); text-align: center; box-shadow: 0 1px 3px var(--shadow); } #demo-banner a { color: var(--accent) !important; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; } /* ── Khối / thẻ ────────────────────────────────────────────────────── */ .gr-group, .block, .gr-box { border-radius: 14px !important; border-color: var(--border) !important; } #settings-card { background: var(--card) !important; border: 1px solid var(--border) !important; border-radius: 16px !important; box-shadow: 0 1px 2px var(--shadow), 0 10px 30px -22px var(--shadow); padding: 6px 16px 14px !important; } .section-label, .section-label p { font-family: 'Fraunces', Georgia, serif !important; font-size: 0.78rem !important; font-weight: 600 !important; letter-spacing: 0.14em; text-transform: uppercase; color: var(--gold) !important; margin: 0.2rem 0 0.1rem !important; } /* Nhãn các control: bỏ kiểu "pill cam" nặng nề của Gradio, chuyển thành nhãn chữ nhỏ thanh thoát. */ .block > label > span, span[data-testid="block-info"], .gr-form > div > label > span { background: transparent !important; color: var(--ink-soft) !important; font-family: 'Literata', serif !important; font-weight: 600 !important; font-size: 0.86rem !important; letter-spacing: 0.01em; padding: 0 0 2px 0 !important; } /* ── Input / textarea ──────────────────────────────────────────────── */ input, textarea, .gr-input, .wrap.svelte-1ipelgc { background: #fffdf7 !important; color: var(--ink) !important; border-color: var(--border) !important; } textarea::placeholder, input::placeholder { color: #b6a88f !important; } textarea:focus, input:focus { border-color: var(--accent) !important; } .source-input textarea, .file-preview textarea, #full-output textarea { font-family: 'Literata', Georgia, serif !important; font-size: 1.02rem !important; line-height: 1.75 !important; } /* ── Nút ───────────────────────────────────────────────────────────── */ button.primary, .gr-button-primary, button[variant="primary"] { background: linear-gradient(180deg, var(--accent-2), var(--accent)) !important; border: 1px solid #93311f !important; color: #fff7ef !important; font-weight: 600 !important; letter-spacing: 0.01em; box-shadow: 0 2px 8px -2px rgba(176,58,38,0.5) !important; transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease !important; } button.primary:hover, .gr-button-primary:hover { filter: brightness(1.05); transform: translateY(-1px); box-shadow: 0 6px 16px -4px rgba(176,58,38,0.55) !important; } button.secondary, .gr-button-secondary { background: #efe6d3 !important; border: 1px solid var(--border) !important; color: var(--ink) !important; font-weight: 600 !important; } button.secondary:hover, .gr-button-secondary:hover { background: #e7dcc4 !important; } /* ── Tabs ──────────────────────────────────────────────────────────── */ /* Bỏ nền/viền/padding thừa của khối bao Tabs để thanh tab liền mạch với nền giấy thay vì nằm trong một thẻ riêng. */ .tabs, .tab-wrapper, .tabitem { background: transparent !important; border: none !important; box-shadow: none !important; padding-top: 0 !important; } .tab-nav { border-bottom: 1px solid var(--border) !important; } .tab-nav button { font-family: 'Fraunces', Georgia, serif !important; font-size: 1rem !important; color: var(--muted) !important; } .tab-nav button.selected { color: var(--accent) !important; border-bottom: 2px solid var(--accent) !important; } /* ── Radio / Checkbox ──────────────────────────────────────────────── */ /* Mặc định (chưa chọn): nền giấy, chữ mực — ghi đè màu tối còn sót của dark-mode Gradio (đây là chỗ widget bị "đen" trên nền sáng). */ .gr-check-radio label, fieldset label:has(input[type="radio"]), fieldset label:has(input[type="checkbox"]) { background: #f1e7d4 !important; color: var(--ink) !important; border: 1px solid var(--border) !important; border-radius: 9px !important; transition: background 0.12s ease, border-color 0.12s ease !important; } .gr-check-radio label:hover, fieldset label:has(input[type="radio"]):hover, fieldset label:has(input[type="checkbox"]):hover { background: #ebe0ca !important; border-color: var(--gold) !important; } /* Đang chọn: nền son nhạt, viền son */ .gr-check-radio label.selected, label.selected, fieldset label:has(input:checked) { background: var(--color-accent-soft) !important; border-color: var(--accent) !important; color: var(--ink) !important; } /* Chấm radio / ô checkbox khi tick */ input[type="radio"]:checked, input[type="checkbox"]:checked { background-color: var(--accent) !important; border-color: var(--accent) !important; } /* ── Accordion "Tuỳ chọn chuẩn hóa xưng hô" (collapse, mặc định đóng) ─── 3 checkbox cùng chủ đề, tách hẳn khỏi card cấu hình dịch. Label gọn 1 dòng, bỏ info dài của Gradio (gây ngộp) → 1 dòng hint chung bên dưới. */ #honorific-accordion label { font-weight: 500 !important; white-space: nowrap; } #honorific-accordion .gr-check-radio span, #honorific-accordion label > span:not(:first-child) { font-size: 0.92rem !important; } /* Gradio render info trong block-info/.info — ẩn để khỏi chiếm 3-4 dòng/ô */ #honorific-accordion div[data-testid="block-info"], #honorific-accordion .info { display: none !important; } /* dòng hint chung: nhỏ, italic, mực nhạt */ #honorific-accordion .honorific-hint, #honorific-accordion .honorific-hint p { font-size: 0.82rem !important; font-style: italic; color: var(--muted) !important; margin: 10px 2px 2px !important; line-height: 1.45 !important; } /* chip "thử nghiệm": son nhạt trên giấy, không in nghiêng */ #honorific-accordion .honorific-hint .exp-badge { display: inline-block; font-style: normal; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.02em; color: var(--accent); background: rgba(176, 58, 38, 0.10); border: 1px solid rgba(176, 58, 38, 0.28); border-radius: 999px; padding: 1px 9px; margin-right: 6px; vertical-align: 1px; white-space: nowrap; } /* ── Slider ────────────────────────────────────────────────────────── */ input[type="range"]::-webkit-slider-thumb { background: var(--accent) !important; } .gr-slider .head, .slider_input_container .slider { accent-color: var(--accent) !important; } /* Beam: chỉ chọn 1–4 → ẩn ô nhập số + nút reset (thừa, gây lệch), giữ thanh kéo. Scoped #beam-slider để KHÔNG ảnh hưởng ô số của slider Batch size (cần nhập tay). Gradio 6 đặt nút reset aria-label="Reset to default value". */ #beam-slider input[type="number"], #beam-slider .number-input, #beam-slider button[aria-label*="eset"], #beam-slider button[title*="eset"] { display: none !important; } /* ── Thanh tiến trình tuỳ biến ─────────────────────────────────────── */ .progress-wrap { border: 1px solid var(--border); background: var(--card); border-radius: 14px; padding: 14px 18px; box-shadow: 0 1px 2px var(--shadow); } .progress-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 9px; } .progress-pct { font-family: 'Fraunces', Georgia, serif; font-size: 1.15rem; font-weight: 600; color: var(--accent); } .progress-state { font-size: 0.85rem; color: var(--muted); font-style: italic; } .progress-track { height: 9px; border-radius: 999px; background: #e6dac2; overflow: hidden; box-shadow: inset 0 1px 2px rgba(90,66,30,0.18); } .progress-fill { position: relative; overflow: hidden; height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--gold)); transition: width 0.3s ease; } /* Sọc chéo chạy (tông đỏ chu sa cùng nút Dịch) — báo "đang dịch" rõ, hợp tông giấy cũ. Đặt trên ::after để Gradio diff giữ node .progress-fill, animation không reset giữa các lần cập nhật % (gr.HTML 6.18 diff in-place). */ .progress-fill.is-running::after { content: ""; position: absolute; inset: 0; border-radius: 999px; background-image: repeating-linear-gradient( 45deg, rgba(255,255,255,0.00) 0 11px, rgba(122,32,20,0.55) 11px 22px); background-size: 31px 31px; animation: progress-stripes 0.65s linear infinite; } @keyframes progress-stripes { to { background-position: 31px 0; } } .progress-msg { margin-top: 8px; font-size: 0.92rem; color: var(--ink-soft); } /* ── Khung đối chiếu song song (xen kẽ câu Trung · Việt) ────────────── */ #compare-view { max-height: 560px; overflow-y: auto; padding-right: 6px; } /* Thanh cuộn mảnh hợp tông giấy */ #compare-view::-webkit-scrollbar { width: 9px; } #compare-view::-webkit-scrollbar-thumb { background: #d2c3a6; border-radius: 999px; } #compare-view::-webkit-scrollbar-track { background: transparent; } .compare-list { display: flex; flex-direction: column; } /* Lưới 3 cột: số thứ tự · câu Trung · câu Việt. Cột 1fr/1fr tự wrap nên không tràn ngang (lỗi cũ của Dataframe). */ .cmp-head, .cmp-row { display: grid; grid-template-columns: 2rem 1fr 1fr; gap: 16px; align-items: start; } .cmp-head { position: sticky; top: 0; z-index: 2; padding: 4px 8px 8px; background: linear-gradient(180deg, var(--card) 70%, rgba(251,246,234,0)); border-bottom: 1px solid var(--border); } .cmp-col { font-family: 'Fraunces', Georgia, serif; font-size: 0.82rem; font-weight: 600; letter-spacing: 0.04em; color: var(--gold); text-transform: uppercase; } .cmp-row { padding: 13px 8px 14px; border-bottom: 1px solid var(--border-soft); border-radius: 10px; transition: background 0.12s ease; } .cmp-row:last-child { border-bottom: none; } /* Linked highlight: rê vào hàng → cả ô Trung và ô Việt cùng sáng */ .cmp-row:hover { background: #f4e8d2; } .cmp-num { grid-column: 1; min-width: 1.8rem; height: 1.8rem; display: inline-flex; align-items: center; justify-content: center; font-family: 'Fraunces', Georgia, serif; font-size: 0.82rem; font-weight: 600; color: var(--accent); background: #f3e7d3; border: 1px solid var(--border); border-radius: 999px; } .cmp-row:hover .cmp-num { background: var(--accent); color: #fff7ef; border-color: var(--accent); } .cmp-zh { grid-column: 2; min-width: 0; font-family: 'Noto Serif SC', serif; font-size: 1.0rem; line-height: 1.7; color: var(--ink-soft); margin: 0; } .cmp-vi { grid-column: 3; min-width: 0; font-family: 'Literata', Georgia, serif; font-size: 1.02rem; line-height: 1.7; color: var(--ink); margin: 0; } .cmp-note { padding: 12px 6px 2px; font-size: 0.88rem; font-style: italic; color: var(--muted); } .compare-empty { padding: 22px 8px; text-align: center; color: var(--muted); font-style: italic; font-size: 0.95rem; } /* ── Accordion ─────────────────────────────────────────────────────── */ /* Header accordion mặc định lấy --body-text-color (trắng ở dark-mode) → gần như vô hình trên nền giấy. Ép chữ mực + nền giấy cho rõ. */ .gr-accordion { background: var(--card) !important; border: 1px solid var(--border) !important; border-radius: 12px !important; } .gr-accordion .label-wrap, .gr-accordion .label-wrap span, .gr-accordion button.label-wrap { color: var(--ink-soft) !important; font-family: 'Fraunces', Georgia, serif !important; font-weight: 600 !important; } .gr-accordion .label-wrap:hover, .gr-accordion .label-wrap:hover span { color: var(--accent) !important; } .gr-accordion .label-wrap .icon, .gr-accordion .label-wrap svg { color: var(--accent) !important; } /* ── Khối thông tin máy / engine ───────────────────────────────────── */ .info-card { background: var(--card-soft) !important; border: 1px solid var(--border-soft) !important; border-radius: 12px !important; padding: 10px 16px !important; font-size: 0.9rem; color: var(--ink-soft) !important; } .info-card p { margin: 0.2rem 0 !important; color: var(--ink-soft) !important; } .info-card strong, .info-card code { color: var(--ink) !important; } code, .prose code { background: #efe4cf !important; color: var(--accent) !important; border-radius: 5px; padding: 0.05em 0.4em; } /* ── Toast thông báo / lỗi ─────────────────────────────────────────── */ /* Ghi đè trực tiếp (biến cấp .dark của Gradio thắng được khai báo :root), đảm bảo toast luôn nền giấy + chữ son, không bị nền đen của dark-mode. */ .toast-body { background: var(--card) !important; border: 1px solid var(--border) !important; color: var(--ink) !important; box-shadow: 0 8px 28px -8px var(--shadow) !important; } .toast-body.error, .toast-body.warning, .toast-body.info { border-left: 4px solid var(--accent) !important; } .toast-title, .toast-text, .toast-body * { color: var(--ink) !important; } .toast-icon, .toast-body svg { color: var(--accent) !important; fill: var(--accent) !important; } .toast-close { color: var(--muted) !important; } /* Thanh đếm ngược của toast */ .timer { background: var(--accent) !important; } /* ── Banner kết quả ────────────────────────────────────────────────── */ /* Rỗng (chưa dịch) → ẩn hẳn, tránh thẻ kem trống chiếm chỗ. */ #result-summary:not(:has(p)):not(:has(li)) { display: none !important; } #result-summary:has(p), #result-summary:has(li) { background: linear-gradient(180deg, #f7ece0, #f2e3d2) !important; border: 1px solid var(--border) !important; border-left: 4px solid var(--accent) !important; border-radius: 12px !important; padding: 12px 18px !important; margin: 4px 0 6px !important; } #result-summary p { margin: 0.15rem 0 !important; color: var(--ink) !important; } /* ── Badge trạng thái tải model ────────────────────────────────────── */ #model-badge { margin: -0.2rem 0 0.1rem; } .model-meta { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } .model-hf-link { font-size: 0.8rem; color: var(--accent) !important; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.15s; } .model-hf-link:hover { border-bottom-color: var(--accent); } .model-badge { display: inline-block; font-size: 0.82rem; font-weight: 500; padding: 0.18em 0.7em; border-radius: 999px; border: 1px solid var(--border); letter-spacing: 0.01em; } .model-badge.ready { background: #e8efe0; color: #3f5a32; border-color: #c2d2b0; } .model-badge.pending { background: #f6ecd6; color: var(--gold); border-color: #ddcaa5; } /* ── Gợi ý bật GPU (chỉ hiện khi có GPU NVIDIA nhưng đang chạy CPU) ──── */ #gpu-hint-box { background: linear-gradient(180deg, #f3ece2, #efe6d4) !important; border: 1px solid var(--border) !important; border-left: 4px solid var(--gold) !important; border-radius: 12px !important; padding: 12px 16px !important; margin: 4px 0 2px !important; } .gpu-hint { color: var(--ink-soft); font-size: 0.9rem; line-height: 1.5; margin-bottom: 0.5rem; } .gpu-hint b { color: var(--ink); } footer { display: none !important; } """ def write_pid_file() -> None: PID_FILE.write_text(str(os.getpid()), encoding="utf-8") def remove_pid_file() -> None: PID_FILE.unlink(missing_ok=True) def resolve_batch_size(auto_batch: bool, manual_batch: float) -> int: if auto_batch: profile = detect_hardware_profile() translator.apply_hardware_profile(profile) return profile.batch_size batch = int(manual_batch) translator.set_batch_size(batch) return batch def ensure_model(model_key: str, backend: str, beam_size: float) -> str: status = translator.load(model_key, backend=backend) beam = HachimiTranslator.clamp_beam(beam_size) return f"{status} · beam={beam} · batch={translator.batch_size}" def on_auto_batch_toggle(auto_batch: bool) -> dict: profile = detect_hardware_profile() return gr.update(value=profile.batch_size, interactive=not auto_batch) def _model_hf_link(config) -> str: url = html.escape(f"https://huggingface.co/{config.model_id}", quote=True) return ( f'' "↗ Trang Hugging Face" ) def render_model_badge(model_key: str, backend: str) -> str: """Badge cho biết model (theo engine đang chọn) đã tải sẵn hay sẽ phải tải, kèm link tới trang Hugging Face của model.""" if model_key not in MODELS: return "" config = MODELS[model_key] link = _model_hf_link(config) if is_model_downloaded(model_key, backend): badge = '✓ Đã tải — dịch được ngay' return f'
{badge}{link}
' if backend == Backend.CT2.value and config.ct2_size_mb: size = f" (~{config.ct2_size_mb} MB)" elif backend != Backend.CT2.value: size = " (bản PyTorch, nặng hơn)" else: size = "" badge = ( f'⬇ Chưa có{size} — ' "sẽ tự tải từ Hugging Face ở lần dịch đầu" ) return f'
{badge}{link}
' def on_model_change(model_key: str, backend: str) -> tuple[float, str]: return float(MODELS[model_key].default_beam), render_model_badge(model_key, backend) def install_gpu_torch_ui(): """Handler nút 'Cài torch để bật GPU'. Stream log pip realtime vào textbox. Yield TỪNG dòng pip ngay khi có, nên người dùng thấy tiến trình tải/cài liên tục (không bị 'đứng hình' như khi gọi hàm blocking rồi mới in một lần).""" if not gpu_available_but_idle(): yield gr.update(), "Không cần cài: máy không có GPU NVIDIA đang rảnh." return from gpu_setup import ( _stream_pip, choose_cuda_channel, torch_install_command, verify_ct2_cuda, verify_torch_cuda, ) channel = choose_cuda_channel(hardware.DRIVER_CUDA_VERSION) if channel is None: yield gr.update(), ( "Driver NVIDIA quá cũ so với các bản torch CUDA hiện có. " "Hãy cập nhật driver rồi thử lại." ) return logs = [ f"Cài torch CUDA ({channel}) — tải ~2–3 GB, cần ~5 GB ổ trống. " "Vui lòng đợi và ĐỪNG tắt app…", ] yield gr.update(interactive=False, value="Đang cài… đừng tắt app"), "\n".join(logs) exit_code = None for line in _stream_pip(torch_install_command(channel)): if line.startswith("__EXIT__:"): exit_code = int(line.split(":", 1)[1]) continue if line: logs.append(line) # Giữ textbox gọn: chỉ hiện 200 dòng cuối. yield gr.update(interactive=False), "\n".join(logs[-200:]) if exit_code != 0: logs.append("") logs.append(f"❌ Cài thất bại (mã {exit_code}). Kiểm tra mạng/đĩa hoặc cài thủ công (README).") yield ( gr.update(interactive=True, value="Thử cài lại"), "\n".join(logs[-200:]), ) return # pip exit 0 chưa chắc có CUDA (vd đã có torch-CPU). Xác minh thật. logs.append("") logs.append("Đang kiểm tra torch và CTranslate2 có nhận GPU không…") yield gr.update(interactive=False), "\n".join(logs[-200:]) torch_ok, verify_msg = verify_torch_cuda() logs.append(("✅ " if torch_ok else "❌ ") + verify_msg) ct2_ok = False if torch_ok: ct2_ok, ct2_msg = verify_ct2_cuda() logs.append(("✅ " if ct2_ok else "❌ ") + ct2_msg) ok = torch_ok and ct2_ok if ok: logs.append("Hãy TẮT và MỞ LẠI app (stop.bat rồi start.bat) để bật GPU.") yield ( gr.update( interactive=not ok, value="Cài xong — khởi động lại app" if ok else "Thử cài lại", ), "\n".join(logs[-200:]), ) def _engine_hint_text(backend: str) -> str: if backend == Backend.CT2.value: return "**Engine:** CTranslate2 — khuyên dùng, tận dụng GPU + batch tốt, nhanh nhất." return ( "**Engine:** PyTorch — đã hỗ trợ batch GPU, nhưng vẫn chậm hơn CT2. " "GPU % thấp là bình thường với model ~60M." ) def on_backend_change(backend: str, model_key: str) -> tuple[str, str]: """Đổi engine → cập nhật cả gợi ý engine lẫn badge trạng thái tải của model.""" return _engine_hint_text(backend), render_model_badge(model_key, backend) def render_progress_html(pct: float, message: str, running: bool) -> str: status = "Đang dịch…" if running else "Sẵn sàng" safe_message = html.escape(message, quote=True) safe_status = html.escape(status, quote=True) running_cls = " is-running" if running else "" return f"""
{pct:.0f}% {safe_status}
{safe_message}
""" def _session_key(request: gr.Request | None) -> str | None: """session_hash của phiên Gradio; None (không lấy được) rơi về key mặc định trong progress_tracker — tức hành vi global cũ, không tệ hơn trước.""" return getattr(request, "session_hash", None) if request is not None else None def poll_progress_ui(active: bool, request: gr.Request | None = None) -> str: if not active: return gr.update() state = snapshot(session=_session_key(request)) return render_progress_html(state.pct, state.message, state.running) EMPTY_COMPARE_HTML = ( '
Dịch xong, bản gốc và bản dịch sẽ hiện đối chiếu ở đây ' "— từng câu một, cuộn dọc để đọc soát.
" ) def render_compare_html(rows: list[tuple[int, str, str]]) -> str: """Render đối chiếu song song 2 cột (Trung | Việt), mỗi chunk một hàng. Mỗi hàng là một chunk (câu hoặc cả đoạn, tùy chế độ chia) đã khớp sẵn từ backend → rê chuột vào một hàng thì cả ô Trung lẫn ô Việt cùng sáng (linked highlight kiểu Google Dịch), chỉ bằng CSS :hover vì đây là markup của ta trong gr.HTML.""" if not rows: return EMPTY_COMPARE_HTML display = rows[:MAX_TABLE_ROWS] items = [ '
' '' '
Tiếng Trung
' '
Tiếng Việt
' "
" ] for idx, zh, vi in display: safe_zh = html.escape(zh, quote=True) safe_vi = html.escape(vi, quote=True) items.append( f'
' f'{idx}' f'

{safe_zh}

' f'

{safe_vi}

' f"
" ) note = "" if len(rows) > MAX_TABLE_ROWS: note = ( f'
Hiển thị {MAX_TABLE_ROWS}/{len(rows)} đoạn đầu. ' "Xem bản dịch đầy đủ ở khối bên dưới.
" ) return f'
{"".join(items)}{note}
' def _decoded_text_score(text: str) -> int: """Lower is better. Helps avoid Big5 bytes decoded as valid GB18030 junk.""" score = 0 for char in text: if char == "\ufffd": score += 100 continue if char in "\n\r\t": continue category = unicodedata.category(char) if category.startswith("C"): score += 20 continue name = unicodedata.name(char, "") if ( "HIRAGANA" in name or "KATAKANA" in name or "BOPOMOFO" in name or "HANGUL" in name ): score += 6 return score def _candidate_decode_score(text: str) -> int: return _decoded_text_score(text[:DECODE_SCORE_SAMPLE_CHARS]) def _utf8_sequence_length(first_byte: int) -> int: if 0xC2 <= first_byte <= 0xDF: return 2 if 0xE0 <= first_byte <= 0xEF: return 3 if 0xF0 <= first_byte <= 0xF4: return 4 return 0 def _is_utf8_punctuation_patch(char: str) -> bool: codepoint = ord(char) if 0x3000 <= codepoint <= 0x303F: # CJK symbols/punctuation, e.g. 《》 return True if 0xFF00 <= codepoint <= 0xFFEF: # fullwidth forms return True return char in "—–“”‘’…" UTF8_LEGACY_PATCH_CHARS = ( "《》〈〉「」『』【】()〔〕,。!?;:“”‘’、—–…" ) def _decode_mixed_utf8_legacy_fast(data: bytes, encoding: str) -> str | None: patched = data changed = False for char in UTF8_LEGACY_PATCH_CHARS: try: source = char.encode("utf-8") target = char.encode(encoding) except UnicodeEncodeError: continue if source in patched: patched = patched.replace(source, target) changed = True if not changed: return None try: return patched.decode(encoding) except UnicodeDecodeError: return None def _decode_utf8_char_at(data: bytes, index: int) -> tuple[str, int] | None: length = _utf8_sequence_length(data[index]) if not length or index + length > len(data): return None chunk = data[index : index + length] try: char = chunk.decode("utf-8") except UnicodeDecodeError: return None if len(char) != 1: return None return char, index + length def _decode_legacy_char_at( data: bytes, index: int, encoding: str, ) -> tuple[str, int] | None: if data[index] < 0x80: return chr(data[index]), index + 1 lengths = (2, 4) if encoding == "gb18030" else (2,) for length in lengths: if index + length > len(data): continue try: return data[index : index + length].decode(encoding), index + length except UnicodeDecodeError: continue return None def _decode_mixed_utf8_legacy(data: bytes, encoding: str) -> str | None: """Decode legacy Chinese text with occasional UTF-8 punctuation patches. Some Quick Translator-era txt files contain GBK/GB18030 body bytes but UTF-8 CJK punctuation around titles (for example UTF-8 ``《`` + GBK title bytes). Strict legacy decode fails on those UTF-8 punctuation bytes, while UTF-8 replacement decode turns the body into mojibake. Prefer UTF-8 only for known punctuation ranges; normal Chinese body bytes stay on the legacy decoder. """ fast_decoded = _decode_mixed_utf8_legacy_fast(data, encoding) if fast_decoded is not None: return fast_decoded output: list[str] = [] index = 0 patched = False while index < len(data): utf8 = _decode_utf8_char_at(data, index) if ( utf8 is not None and utf8[1] - index >= 3 and _is_utf8_punctuation_patch(utf8[0]) ): output.append(utf8[0]) index = utf8[1] patched = True continue legacy = _decode_legacy_char_at(data, index, encoding) if legacy is not None: output.append(legacy[0]) index = legacy[1] continue if utf8 is not None: output.append(utf8[0]) index = utf8[1] patched = True continue return None if not patched: return None return "".join(output) def _decode_text_bytes(data: bytes) -> str: for encoding in ("utf-8-sig", "utf-8"): try: return data.decode(encoding) except UnicodeDecodeError: continue candidates: list[tuple[int, int, str]] = [] for index, encoding in enumerate(LEGACY_TEXT_ENCODINGS): try: decoded = data.decode(encoding) except UnicodeDecodeError: decoded = None if decoded is not None: candidates.append((_candidate_decode_score(decoded), index * 2, decoded)) mixed_decoded = _decode_mixed_utf8_legacy(data, encoding) if mixed_decoded is not None: candidates.append((_candidate_decode_score(mixed_decoded), index * 2 + 1, mixed_decoded)) if candidates: return min(candidates, key=lambda item: (item[0], item[1]))[2] return data.decode("utf-8", errors="replace") def read_text_file(path: Path, *, max_chars: int | None = None) -> str: text = _decode_text_bytes(path.read_bytes()) return text[:max_chars] if max_chars is not None else text # Dấu kết câu để cắt PREVIEW gọn (không đứt giữa câu). _PREVIEW_SENTENCE_END = "。!?!?…」』)》】”’\n" _PREVIEW_TRUNCATED_NOTICE = ( "⚠️ Xem trước chỉ hiển thị phần đầu file — nút Dịch vẫn dịch TOÀN BỘ file." ) def build_file_preview(text: str, *, max_chars: int = 8000) -> str: """Cắt văn bản preview ở ranh giới câu gần ``max_chars`` + ghi chú nếu cắt. Tránh hiểu nhầm "file bị cắt": ô xem trước chỉ hiển thị phần đầu, còn nút Dịch file đọc toàn bộ. Nếu trong tầm không có dấu câu thì cắt cứng (vẫn ghi chú) để không hiển thị vô hạn. """ if len(text) <= max_chars: return text head = text[:max_chars] cut = max((head.rfind(ch) for ch in _PREVIEW_SENTENCE_END), default=-1) # Chỉ nhận ranh giới câu nếu nó không quá sớm (giữ ≥ 60% nội dung preview). if cut >= int(max_chars * 0.6): head = head[: cut + 1] return f"{head.rstrip()}\n\n{_PREVIEW_TRUNCATED_NOTICE}" def cap_input_for_space(source: str) -> tuple[str, str]: """Trên Space (CPU dùng chung, công khai): cắt văn bản dài để tránh 1 người giữ tài nguyên quá lâu. Trả (văn_bản_đã_cắt, ghi_chú). Local/Colab: trả nguyên văn + ghi chú rỗng.""" if not IS_HF_SPACE or len(source) <= SPACE_MAX_INPUT_CHARS: return source, "" capped = source[:SPACE_MAX_INPUT_CHARS] notice = ( f" ⚠️ Bản Space (CPU dùng chung) giới hạn ~{SPACE_MAX_INPUT_CHARS:,} ký tự/lần " f"— đã dịch phần đầu ({SPACE_MAX_INPUT_CHARS:,}/{len(source):,}). Muốn dịch trọn " f"bộ truyện dài, hãy dùng **Google Colab/Kaggle (GPU)** hoặc **bản cài máy** ở phần " f"\"🚀 Dùng nhanh/mạnh hơn\" bên trên (không giới hạn, nhanh hơn nhiều)." ) return capped, notice def _exception_message(exc: Exception) -> str: return str(exc).strip() or exc.__class__.__name__ def _request_session_token(request: gr.Request | None) -> str: """Return a short, non-reversible scope token without exposing session_hash.""" session_hash = str(getattr(request, "session_hash", "") or "") if not session_hash: return "local" return hashlib.sha256(session_hash.encode("utf-8")).hexdigest()[:12] def _unique_export_path( kind: str, suffix: str, request: gr.Request | None = None, ) -> Path: safe_kind = "".join(c if c.isalnum() or c in "-_" else "_" for c in kind) safe_kind = safe_kind.strip("_")[:80] or "file" safe_suffix = suffix.lower().lstrip(".") if safe_suffix not in {"txt", "tsv", "json"}: raise ValueError(f"Unsupported export suffix: {suffix}") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") session_token = _request_session_token(request) nonce = uuid.uuid4().hex return EXPORTS_DIR / ( f"{EXPORT_FILE_PREFIX}{safe_kind}_{timestamp}_{session_token}_{nonce}.{safe_suffix}" ) def _cleanup_old_space_exports( *, now: float | None = None, max_age_seconds: int = SPACE_EXPORT_MAX_AGE_SECONDS, ) -> None: """Best-effort cleanup of this app's old ephemeral exports on HF Space only.""" if not IS_HF_SPACE: return cutoff = (time.time() if now is None else now) - max_age_seconds try: candidates = list(EXPORTS_DIR.iterdir()) except OSError: return for path in candidates: if ( not path.is_file() or not path.name.startswith(EXPORT_FILE_PREFIX) or path.suffix.lower() not in {".txt", ".tsv", ".json"} ): continue try: if path.stat().st_mtime < cutoff: path.unlink() except OSError: continue def export_translation( full_text: str, filename_stem: str, request: gr.Request | None = None, ) -> str | None: if not full_text.strip(): raise gr.Error("Chưa có bản dịch để xuất.") safe_stem = "".join(c if c.isalnum() or c in "-_" else "_" for c in filename_stem) or "translation" _cleanup_old_space_exports() out_path = _unique_export_path(f"{safe_stem}_vi", "txt", request) out_path.write_text(full_text, encoding="utf-8") return str(out_path) def export_translation_ui( full_text: str, request: gr.Request | None = None, ) -> str | None: return export_translation(full_text, "hachimimt", request) def add_glossary_row_ui(rows) -> list[list[object]]: """Append one editable glossary row without relying on Gradio's cell menu.""" if isinstance(rows, dict): rows = rows.get("data", []) elif hasattr(rows, "tolist"): rows = rows.tolist() blank_row: list[object] = ["", "", "", "", True] table_rows: list[list[object]] = [] if isinstance(rows, (list, tuple)): for row in rows: if not isinstance(row, (list, tuple)): continue normalized = blank_row.copy() for index, value in enumerate(row[: len(blank_row)]): normalized[index] = value table_rows.append(normalized) table_rows.append(blank_row.copy()) return table_rows def import_glossary_ui(file_obj) -> tuple[list[list[object]], str]: if file_obj is None: raise gr.Error("Chọn file glossary .tsv hoặc .json.") try: rows = read_glossary_file(Path(file_obj)) except (OSError, GlossaryValidationError) as exc: raise gr.Error(f"Không nạp được glossary: {_exception_message(exc)}") from exc return rows, f"Đã nạp **{len(rows)}** mục từ `{Path(file_obj).name}`." def export_glossary_ui( rows, file_format: str, request: gr.Request | None = None, ) -> tuple[str, str]: try: table_rows = glossary_table_rows(rows) except GlossaryValidationError as exc: raise gr.Error(f"Glossary không hợp lệ: {_exception_message(exc)}") from exc if not table_rows: raise gr.Error("Glossary đang trống.") file_format = (file_format or "tsv").strip().lower() if file_format not in {"tsv", "json"}: raise gr.Error("Định dạng glossary phải là TSV hoặc JSON.") _cleanup_old_space_exports() out_path = _unique_export_path("glossary", file_format, request) try: write_glossary_file(out_path, table_rows, file_format=file_format) except (OSError, GlossaryValidationError) as exc: raise gr.Error(f"Không xuất được glossary: {_exception_message(exc)}") from exc return str(out_path), f"Đã xuất **{len(table_rows)}** mục glossary." def _format_duration(seconds: float) -> str: """Định dạng thời gian gọn: '12,3 giây' hoặc '2 phút 5 giây'.""" if seconds < 60: return f"{seconds:.1f} giây".replace(".", ",") minutes = int(seconds // 60) rem = int(round(seconds - minutes * 60)) if rem == 0: return f"{minutes} phút" return f"{minutes} phút {rem} giây" def _clamp_full_text(full_text: str) -> str: """Giới hạn text hiển thị trong ô 'Bản dịch đầy đủ' để tránh lag với file lớn. Textbox chứa vài MB (truyện hàng chục nghìn câu) làm trình duyệt giật khi render/cuộn. File xuất .txt vẫn ĐẦY ĐỦ — đây chỉ cắt phần HIỂN THỊ. User đọc trọn bộ bằng nút tải file bên dưới. """ if len(full_text) <= FULL_OUTPUT_DISPLAY_LIMIT: return full_text head = full_text[:FULL_OUTPUT_DISPLAY_LIMIT].rsplit("\n", 1)[0] omitted = len(full_text) - len(head) omitted_label = f"{omitted:,}".replace(",", ".") return ( f"{head}\n\n" f"────────────────────\n" f"[Đã ẩn ~{omitted_label} ký tự còn lại để tránh lag. " f"Bản dịch ĐẦY ĐỦ đã lưu — bấm “Xuất bản dịch .txt” / tải file bên dưới.]" ) def _build_feedback_context( *, rows, run_id, source_kind, model, backend, chunk_mode, normalize_mode, honorific, pronoun_v9, ) -> dict: """Snapshot toàn ngữ cảnh bản dịch để panel feedback đọc (KHÔNG đọc control hiện tại — user có thể đã đổi sau khi dịch).""" from feedback_log import SCHEMA_VERSION return { "rows": rows, "run_id": run_id, "schema_version": SCHEMA_VERSION, "source_kind": source_kind, "model": model, "backend": backend, "chunk_mode": chunk_mode, "normalize_mode": normalize_mode, "honorific": honorific, "pronoun_v9": pronoun_v9, } def _build_results( rows: list[tuple[int, str, str]], full_text: str, status: str, summary: str, download_path: str | None, context: dict, ) -> tuple: return ( render_compare_html(rows), _clamp_full_text(full_text), status, download_path, summary, full_text, # bản đầy đủ → gr.State cho nút Xuất .txt context, # snapshot → gr.State cho panel feedback ) def _progress_stream_update(active: bool = True, *, session: str | None = None) -> tuple: state = snapshot(session=session) return ( render_progress_html(state.pct, state.message, state.running), active, *(gr.update() for _ in range(RESULT_OUTPUT_COUNT)), ) def _progress_stream_final(results: tuple, *, session: str | None = None) -> tuple: state = snapshot(session=session) return (render_progress_html(state.pct, state.message, state.running), False, *results) def prepare_progress_ui(message: str, *, session: str | None = None) -> tuple[str, bool]: set_progress(0, message, session=session) return render_progress_html(0, message, True), True def prepare_text_progress_ui(source: str, request: gr.Request | None = None) -> tuple[str, bool]: if not source.strip(): raise gr.Error("Nhập văn bản tiếng Trung cần dịch.") return prepare_progress_ui("Đang chuẩn bị dịch văn bản...", session=_session_key(request)) def prepare_file_progress_ui(file_obj, request: gr.Request | None = None) -> tuple[str, bool]: if file_obj is None: raise gr.Error("Chọn file .txt cần dịch.") path = Path(file_obj) if path.suffix.lower() != ".txt": raise gr.Error("Chỉ hỗ trợ file .txt") return prepare_progress_ui(f"Đang đọc file {path.name}...", session=_session_key(request)) def _layout_lines(text: str) -> list[str]: """Tách giữ nguyên bố cục dòng (kể cả dòng trống đầu/cuối), chuẩn hóa newline.""" return (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") def rebuild_paragraph_layout( source_text: str, rows: list[tuple[int, str, str]] ) -> str: """Ghép bản dịch của các dòng (rows) trở lại ĐÚNG bố cục dòng của nguồn. rows chỉ chứa dòng nguồn KHÔNG rỗng (1 row / 1 dòng non-blank); hàm này đặt chúng lại theo vị trí TUYỆT ĐỐI, chèn dòng trống đúng chỗ nguồn có dòng trống. Dùng cho chế độ "Theo đoạn" để hậu xử lý không làm mất ranh giới đoạn. """ translated_iter = iter(vi for _, _, vi in rows) output: list[str] = [] for source_line in _layout_lines(source_text): if source_line.strip(): output.append(next(translated_iter, "")) else: output.append("") sentinel = object() if next(translated_iter, sentinel) is not sentinel: raise RuntimeError("Số row dịch lớn hơn số dòng nguồn không rỗng") return "\n".join(output) def _result_text_from_rows( source_text: str, rows: list[tuple[int, str, str]], backend: str, ) -> str: if backend == Backend.CT2.value: return rebuild_paragraph_layout(source_text, rows) return "\n".join(translated_vi for _, _, translated_vi in rows) def _retry_glossary_failed_rows( rows: list[tuple[int, str, str]], failed_indices: tuple[int, ...], *, beam_size: int, ) -> list[tuple[int, str, str]]: """Translate failed placeholder rows again from their original source.""" failed = set(failed_indices) source_by_index = { index: source_zh for index, source_zh, _translated_vi in rows if index in failed } missing = failed - set(source_by_index) if missing: raise RuntimeError( "Không tìm thấy dòng nguồn để fallback glossary: " + ", ".join(str(index) for index in sorted(missing)) ) fallback_by_index: dict[int, str] = {} for index in failed_indices: fallback_text: str | None = None for _done, _total, _message, result_rows, result_text in translator.translate_text_iter( source_by_index[index], chunk_mode="paragraph", beam_size=beam_size, ): if result_rows is not None and result_text is not None: fallback_text = result_text if fallback_text is None: raise RuntimeError(f"Fallback glossary không trả kết quả cho dòng {index}.") fallback_by_index[index] = fallback_text.strip() return [ ( index, source_zh, fallback_by_index.get(index, translated_vi), ) for index, source_zh, translated_vi in rows ] def apply_postprocess_rows( rows: list[tuple[int, str, str]], *, honorific_kinship: bool, honorific_pronouns: bool, pronoun_harmonizer_v9: bool, raw_full_text: str | None = None, source_text: str = "", chunk_mode: str = "sentence", ) -> tuple[list[tuple[int, str, str]], str, dict]: raw_rows = list(rows) # raw_full_text = bản đã restore (từ translator). Nếu caller không truyền thì # dựng tạm từ rows. if raw_full_text is None: raw_full_text = "\n".join(vi for _, _, vi in raw_rows) try: fixed_rows, fixed_full_text, report = _apply_postprocess_rows( raw_rows, honorific_kinship=honorific_kinship, honorific_pronouns=honorific_pronouns, pronoun_harmonizer_v9=pronoun_harmonizer_v9, ) # _apply_postprocess_rows trả full_text join phẳng (mất dòng trống). Cả # "Theo câu" lẫn "Theo đoạn" nay đều có rows = 1-dòng-non-blank/row → # dựng lại full_text theo bố cục nguồn (giữ dòng trống) từ rows ĐÃ hậu xử # lý (row count giữ nhờ guard V9). Test cũ không truyền source_text → # giữ đường join phẳng (backward-compat). if source_text: fixed_full_text = rebuild_paragraph_layout(source_text, fixed_rows) return fixed_rows, fixed_full_text, report except Exception as exc: # Trả raw_full_text THẬT (đã restore), không dựng lại từ rows non-blank. return raw_rows, raw_full_text, { "genre_decision": None, "honorific_changed": 0, "honorific_pronouns_effective": False, "honorific_kinship_effective": False, "pronoun_report": {}, "warning": _exception_message(exc), "fallback": "raw", } def _policy_rows(rows: list[tuple[int, str, str]]) -> list[tuple[int, str, str]]: return [ (index, normalize_chinese_text(chunk_zh, NORMALIZE_T2S), translated_vi) for index, chunk_zh, translated_vi in rows ] def _apply_postprocess_rows( rows: list[tuple[int, str, str]], *, honorific_kinship: bool, honorific_pronouns: bool, pronoun_harmonizer_v9: bool, ) -> tuple[list[tuple[int, str, str]], str, dict]: policy_rows = _policy_rows(rows) genre_decision = classify_genre(policy_rows) if policy_rows else None honorific_changed = 0 pronoun_report = {} honorific_pronouns_effective = bool(honorific_pronouns and genre_decision and genre_decision.is_classical) honorific_kinship_effective = bool(honorific_kinship) honorific_on_effective = honorific_kinship_effective or honorific_pronouns_effective if honorific_on_effective and rows: new_rows = [] for (index, chunk_zh, translated_vi), (_, policy_zh, _) in zip(rows, policy_rows): fixed = normalize_honorifics( policy_zh, translated_vi, apply_kinship=honorific_kinship_effective, apply_pronouns=honorific_pronouns_effective, classical_context=genre_decision.is_classical if genre_decision else None, ) if fixed != translated_vi: honorific_changed += 1 new_rows.append((index, chunk_zh, fixed)) rows = new_rows if pronoun_harmonizer_v9 and rows: v9_policy_rows = [ (index, normalize_chinese_text(chunk_zh, NORMALIZE_T2S), translated_vi) for index, chunk_zh, translated_vi in rows ] v9_rows, pronoun_report = harmonize_pronouns_v9( v9_policy_rows, route=v9_route_for_decision(genre_decision) if genre_decision else "unknown_copy_guard", ) if len(v9_rows) != len(rows): raise RuntimeError(f"V9 row count mismatch: expected {len(rows)}, got {len(v9_rows)}") for (original_index, _, _), (fixed_index, _, _) in zip(rows, v9_rows): if original_index != fixed_index: raise RuntimeError(f"V9 row index mismatch: {original_index} != {fixed_index}") rows = [ (index, original_zh, fixed_vi) for (index, original_zh, _), (_, _, fixed_vi) in zip(rows, v9_rows) ] full_text = "\n".join(vi for _, _, vi in rows) return rows, full_text, { "genre_decision": genre_decision, "honorific_changed": honorific_changed, "honorific_pronouns_effective": honorific_pronouns_effective, "honorific_kinship_effective": honorific_kinship_effective, "pronoun_report": pronoun_report, } def _translate_run( source: str, model_key: str, backend: str, beam_size: float, chunk_mode: str, normalize_mode: str, honorific_kinship: bool, honorific_pronouns: bool, pronoun_harmonizer_v9: bool, auto_batch: bool, manual_batch: float, *, glossary_rows=None, filename_stem: str, summary_prefix: str, source_kind: str, session: str | None = None, ) -> Iterator[tuple]: try: source, space_cap_notice = cap_input_for_space(source) original_source = source source = normalize_chinese_text(source, normalize_mode) normalize_msg = normalization_message(original_source, source, normalize_mode) try: glossary_entries = compile_glossary( glossary_rows, normalize_source=lambda term: normalize_chinese_text(term, normalize_mode), ) except GlossaryValidationError as exc: raise gr.Error(f"Glossary không hợp lệ: {_exception_message(exc)}") from exc honorific_kinship = bool(honorific_kinship) honorific_pronouns = bool(honorific_pronouns) honorific_on = honorific_kinship or honorific_pronouns pronoun_harmonizer_v9 = bool(pronoun_harmonizer_v9) placeholder_scope = ( PLACEHOLDER_SCOPE_LINE if backend == Backend.CT2.value else PLACEHOLDER_SCOPE_DOCUMENT ) glossary_protection = protect_glossary_text( source, glossary_entries, scope=placeholder_scope, ) model_source = glossary_protection.text if is_model_downloaded(model_key, backend): load_msg = "Đang nạp model..." else: label = MODELS[model_key].label if model_key in MODELS else model_key load_msg = f"Đang tải model {label} từ Hugging Face (lần đầu, vui lòng đợi)..." set_progress(0, load_msg, session=session) yield _progress_stream_update(session=session) resolve_batch_size(auto_batch, manual_batch) status = ensure_model(model_key, backend, beam_size) protection_note = ( f" Đã bảo vệ {glossary_protection.protected_occurrences} tên/thuật ngữ." if glossary_protection.protected_occurrences else "" ) set_progress( 2, f"{normalize_msg}{protection_note} Đang chia chunk...", session=session, ) yield _progress_stream_update(session=session) rows: list[tuple[int, str, str]] = [] full_text = "" last_progress_update = 0.0 translate_start = time.perf_counter() run_id = uuid.uuid4().hex[:8] for done, total, message, result_rows, result_text in translator.translate_text_iter( model_source, chunk_mode=chunk_mode, beam_size=int(beam_size), ): if result_rows is not None and result_text is not None: rows = result_rows full_text = result_text continue now = time.perf_counter() should_update_progress = ( done == 0 or done == total or now - last_progress_update >= PROGRESS_UPDATE_SECONDS ) if not should_update_progress: continue pct = round(done / max(total, 1) * 100, 1) detail = f"{message} ({pct}%)" set_progress(pct, detail, session=session) last_progress_update = now yield _progress_stream_update(session=session) translate_profile = dict(translator.last_profile) glossary_restore_report = None glossary_retry_rows = 0 if glossary_protection.protected_occurrences: rows, glossary_restore_report = restore_glossary_rows( rows, glossary_protection, ) if glossary_restore_report.failed_indices: glossary_retry_rows = glossary_restore_report.failed_rows set_progress( 99, f"Glossary: dịch lại {glossary_retry_rows} dòng có placeholder lỗi...", session=session, ) yield _progress_stream_update(session=session) rows = _retry_glossary_failed_rows( rows, glossary_restore_report.failed_indices, beam_size=int(beam_size), ) full_text = _result_text_from_rows(source, rows, backend) rows, full_text, postprocess_report = apply_postprocess_rows( rows, honorific_kinship=honorific_kinship, honorific_pronouns=honorific_pronouns, pronoun_harmonizer_v9=pronoun_harmonizer_v9, raw_full_text=full_text, source_text=source, chunk_mode=chunk_mode, ) genre_decision = postprocess_report.get("genre_decision") honorific_changed = int(postprocess_report.get("honorific_changed") or 0) honorific_pronouns_effective = bool(postprocess_report.get("honorific_pronouns_effective")) pronoun_report = postprocess_report.get("pronoun_report") or {} postprocess_warning = str(postprocess_report.get("warning") or "") glossary_report = None glossary_warning = "" if glossary_entries: before_glossary_rows = rows before_glossary_text = full_text try: rows, glossary_report = apply_glossary_rows(rows, glossary_entries) if glossary_report.changed_rows: full_text = _result_text_from_rows(source, rows, backend) except Exception as exc: rows = before_glossary_rows full_text = before_glossary_text glossary_warning = _exception_message(exc) translate_seconds = time.perf_counter() - translate_start # KHÔNG auto-tạo file .txt mỗi lần dịch (tránh rác temp) — file chỉ sinh # khi user bấm nút "Xuất bản dịch .txt" (export_btn.click). Bản dịch đầy đủ # vẫn nằm trong ô + full_text_state để xuất khi cần. download_path = None duration = _format_duration(translate_seconds) chunk_count = int(translate_profile.get("chunks") or len(rows)) fallback_chunks = int(translate_profile.get("paragraph_fallback_chunks") or 0) fallback_lines = int(translate_profile.get("paragraph_fallback_lines") or 0) measured_chunks = max(chunk_count + fallback_chunks, 1) rate = measured_chunks / translate_seconds if translate_seconds > 0 else 0.0 time_note = f"⏱ {duration}" if rate >= 1: time_note += f" ({rate:.0f} chunk/giây)" fallback_note = "" if fallback_lines: fallback_note = ( f" Fallback giữ dòng: dịch lại {fallback_lines} dòng" f" ({fallback_chunks} chunk)." ) honorific_note = "" if honorific_on: parts = [] if honorific_kinship: parts.append("thân tộc") if honorific_pronouns: parts.append("đại từ" if honorific_pronouns_effective else "đại từ bỏ qua") honorific_note = (f" Đã chuẩn hóa xưng hô ({' + '.join(parts)}): " f"{honorific_changed} chunk chỉnh.") pronoun_note = "" if pronoun_harmonizer_v9: pronoun_note = ( " Ổn định ngôi xưng V9: " f"{pronoun_report.get('changed_rows', 0)} chunk chỉnh, " f"route `{pronoun_report.get('route', 'n/a')}`." ) route_note = f" Route hậu kỳ `{genre_decision.route}`." if genre_decision and (honorific_on or pronoun_harmonizer_v9) else "" warning_note = f" Hậu kỳ lỗi, đã giữ bản dịch thô: {postprocess_warning}." if postprocess_warning else "" glossary_note = "" if glossary_report is not None: glossary_note = ( f" Glossary: {glossary_report.entries} mục, " f"{glossary_report.source_hits} hit nguồn; " f"{glossary_report.replacements} thay thế alias, " f"{glossary_report.satisfied} đã đúng, " f"{glossary_report.unresolved} chưa khớp alias." ) if glossary_restore_report is not None: glossary_note += ( f" Placeholder: {glossary_restore_report.protected_occurrences} bảo vệ, " f"{glossary_restore_report.restored_occurrences} restore" ) if glossary_restore_report.skipped_occurrences: glossary_note += ( f", {glossary_restore_report.skipped_occurrences} vượt pool" ) if glossary_retry_rows: glossary_note += f", {glossary_retry_rows} dòng retry" glossary_note += "." elif glossary_warning: glossary_note = ( f" Glossary lỗi, đã giữ bản trước glossary: {glossary_warning}." ) summary = f"{summary_prefix} **{chunk_count}** chunk · {time_note}. {normalize_msg}{fallback_note}{route_note}{honorific_note}{pronoun_note}{warning_note}{glossary_note}{space_cap_notice}" status = f"{status} · {time_note}" finish_progress( f"Hoàn tất — {chunk_count} chunk trong {duration} (100%)", session=session, ) feedback_context = _build_feedback_context( rows=rows, run_id=run_id, source_kind=source_kind, model=model_key, backend=backend, chunk_mode=chunk_mode, normalize_mode=normalize_mode, honorific=_feedback_honorific_label(honorific_kinship, honorific_pronouns), pronoun_v9=pronoun_harmonizer_v9, ) yield _progress_stream_final( _build_results(rows, full_text, status, summary, download_path, feedback_context), session=session, ) except Exception as exc: reset_progress(f"Lỗi: {_exception_message(exc)}", session=session) yield _progress_stream_update(active=False, session=session) raise def translate_text_ui( source: str, model_key: str, backend: str, beam_size: float, chunk_mode: str, normalize_mode: str, honorific_kinship: bool, honorific_pronouns: bool, pronoun_harmonizer_v9: bool, auto_batch: bool, manual_batch: float, glossary_rows=None, request: gr.Request | None = None, ) -> Iterator[tuple]: if not source.strip(): raise gr.Error("Nhập văn bản tiếng Trung cần dịch.") yield from _translate_run( source, model_key, backend, beam_size, chunk_mode, normalize_mode, honorific_kinship, honorific_pronouns, pronoun_harmonizer_v9, auto_batch, manual_batch, glossary_rows=glossary_rows, filename_stem="hachimimt", summary_prefix="Đã dịch", source_kind="text", session=_session_key(request), ) def translate_file_ui( file_obj, model_key: str, backend: str, beam_size: float, chunk_mode: str, normalize_mode: str, honorific_kinship: bool, honorific_pronouns: bool, pronoun_harmonizer_v9: bool, auto_batch: bool, manual_batch: float, glossary_rows=None, request: gr.Request | None = None, ) -> Iterator[tuple]: if file_obj is None: raise gr.Error("Chọn file .txt cần dịch.") path = Path(file_obj) if path.suffix.lower() != ".txt": raise gr.Error("Chỉ hỗ trợ file .txt") source = read_text_file(path) if not source.strip(): raise gr.Error("File trống.") yield from _translate_run( source, model_key, backend, beam_size, chunk_mode, normalize_mode, honorific_kinship, honorific_pronouns, pronoun_harmonizer_v9, auto_batch, manual_batch, glossary_rows=glossary_rows, filename_stem=path.stem, summary_prefix=f"Đã dịch từ `{path.name}` —", source_kind="file", session=_session_key(request), ) def _find_row(idx, rows): for r in rows: if r[0] == idx: return r return None def _load_sentence(idx, context) -> tuple[str, str, str, str]: """Nạp câu theo idx từ context snapshot vào panel.""" rows = (context or {}).get("rows") or [] try: idx = int(idx) except (TypeError, ValueError): return "", "", "", "⚠️ Số thứ tự không hợp lệ." row = _find_row(idx, rows) if row is None: return "", "", "", f"⚠️ Không có câu #{idx} trong bản dịch hiện tại." _, zh, vi = row return zh, vi, vi, f"Đã nạp câu #{idx}. Sửa rồi bấm lưu." def _save_feedback(idx, corrected, category, rating, context, *, require_correction: bool = False) -> str: """Ghi 1 feedback. Metadata lấy TỪ CONTEXT (không từ control hiện tại). require_correction=True: chặn ghi khi ô sửa rỗng (dùng cho nút 💾 và Lưu & câu tiếp). require_correction=False (mặc định): cho phép ghi không có bản sửa (dùng cho nút 👎). """ from feedback_log import FeedbackEntry, SCHEMA_VERSION, append_feedback ctx = context or {} rows = ctx.get("rows") or [] try: idx = int(idx) except (TypeError, ValueError): return "⚠️ Hãy chọn câu trước (số thứ tự không hợp lệ)." row = _find_row(idx, rows) if row is None: return "⚠️ Hãy chọn câu hợp lệ trước khi lưu." _, zh, vi = row if rating == "good": corrected_val = None category_val = None # 👍 → category vô nghĩa, ép None (tránh nhiễu) else: corrected_val = (corrected or "").strip() or None category_val = category or None if require_correction and corrected_val is None: return "⚠️ Ô sửa trống — hãy sửa câu hoặc dùng 👎 nếu chỉ muốn báo lỗi." if require_correction and corrected_val == (vi or "").strip(): return "⚠️ Bản sửa chưa khác bản dịch hiện tại — hãy sửa câu trước khi lưu." try: append_feedback(FeedbackEntry( schema_version=SCHEMA_VERSION, ts=datetime.now().astimezone().isoformat(timespec="seconds"), run_id=ctx.get("run_id", ""), source_kind=ctx.get("source_kind", ""), model=ctx.get("model", ""), backend=ctx.get("backend", ""), chunk_mode=ctx.get("chunk_mode", ""), normalize_mode=ctx.get("normalize_mode", ""), honorific=ctx.get("honorific", "none"), pronoun_v9=bool(ctx.get("pronoun_v9", False)), category=category_val, idx=idx, source=zh, mt_final=vi, corrected=corrected_val, rating=rating, )) except Exception as exc: # ghi lỗi không làm sập app return f"⚠️ Lỗi ghi feedback: {exc}" verb = "👍 Tốt" if rating == "good" else "Đã lưu" return f"✓ {verb} câu #{idx}." def _save_and_next(idx, corrected, category, context) -> tuple: """Lưu (rating=bad, yêu cầu có sửa) rồi nạp câu kế. Lưu lỗi/trống → không tiến.""" status = _save_feedback(idx, corrected, category, "bad", context, require_correction=True) if status.startswith("⚠️"): # Lưu không thành (ô trống hoặc lỗi) → giữ nguyên câu, không nhảy rows = (context or {}).get("rows") or [] try: cur = int(idx) except (TypeError, ValueError): row = None else: row = _find_row(cur, rows) if row is None: return idx, "", "", "", status _, zh, vi = row return idx, zh, vi, corrected, status rows = (context or {}).get("rows") or [] try: cur = int(idx) except (TypeError, ValueError): return idx, "", "", "", status nxt = cur + 1 if _find_row(nxt, rows) is None: return cur, "", "", "", status + " Đã hết câu." zh, vi, edit, load_status = _load_sentence(nxt, context) return nxt, zh, vi, edit, status + f" → {load_status}" def build_result_outputs(base_outputs: list, feedback_context_state) -> list: """result_outputs LUÔN gồm feedback_context_state ở cuối (khớp 7-tuple của _build_results) dù panel feedback có hiện hay không.""" return [*base_outputs, feedback_context_state] def build_ui() -> gr.Blocks: model_choices = [(cfg.label, key) for key, cfg in MODELS.items()] with gr.Blocks(title="HachimiMT — Dịch Trung Việt") as demo: # ── Header ──────────────────────────────────────────────────── with gr.Column(elem_id="app-header"): gr.Markdown("# HachimiMT", elem_id="app-title") gr.HTML('
') # ── Banner demo (chỉ trên Space) ────────────────────────────── if IS_HF_SPACE: gr.HTML( '
' 'Đây là bản demo chạy CPU — phù hợp dịch thử đoạn ngắn. ' 'Cần GPU, dịch file/chương dài hay chạy offline? ' f'☁️ chạy trên Google Colab ' f'/ Kaggle T4×2 ' f'hoặc ⬇ tải bản cài máy ' '(chi tiết ở mục “Dùng nhanh/mạnh hơn” cuối trang).' "
" ) # ── Khu cấu hình ────────────────────────────────────────────── with gr.Group(elem_id="settings-card"): gr.Markdown("Cấu hình dịch", elem_classes=["section-label"]) with gr.Row(): model_select = gr.Dropdown( choices=model_choices, value="HachimiMT-60", label="Model dịch", scale=3, ) backend_select = gr.Radio( [("CTranslate2 — nhanh", Backend.CT2.value), ("PyTorch", Backend.TRANSFORMERS.value)], value=Backend.CT2.value, label="Engine", scale=3, ) # Space (CPU): default beam=1 (nhanh hơn rõ, demo ưu tiên tốc độ); # local: beam=2 (chất lượng nhỉnh hơn, máy mạnh). User vẫn kéo được. beam_size = gr.Slider(1, 4, value=(1 if IS_HF_SPACE else 2), step=1, label="Beam (1–4)", scale=2, elem_id="beam-slider", info="Cao hơn = dịch kỹ hơn, chậm hơn") chunk_mode = gr.Radio( [("Theo câu", "sentence"), ("Theo đoạn", "paragraph")], value="sentence", label="Chia chunk", scale=2, info="Theo câu: bám sát từng dòng. Theo đoạn: gom dòng cùng đoạn " "để thêm ngữ cảnh, tự dịch lại từng dòng khi restore rủi ro.", ) normalize_mode = gr.Dropdown( [ ("Tự động phồn → giản", NORMALIZE_AUTO), ("Ép phồn → giản", NORMALIZE_T2S), ("Giữ nguyên", NORMALIZE_NONE), ], value=NORMALIZE_AUTO, label="Chuẩn hóa chữ Hán", scale=2, ) with gr.Row(): auto_batch = gr.Checkbox(value=True, label="Tự động batch theo CPU/GPU", scale=2) manual_batch = gr.Slider( 4, 128, value=HW_PROFILE.batch_size, step=4, label="Batch size (chunk/lần)", interactive=False, scale=3, ) model_badge = gr.HTML( render_model_badge("HachimiMT-60", Backend.CT2.value), elem_id="model-badge", ) engine_hint = gr.Markdown(_engine_hint_text(Backend.CT2.value)) status = gr.Textbox( label="Trạng thái", value="Sẵn sàng — chọn cấu hình rồi bắt đầu dịch.", interactive=False, elem_classes=["status-box"], ) _gpu_idle = gpu_available_but_idle() with gr.Group(visible=_gpu_idle, elem_id="gpu-hint-box") as gpu_hint_group: gpu_hint = gr.HTML(render_gpu_hint_html()) gpu_install_btn = gr.Button( "⚡ Cài torch để bật GPU (tải ~2–3 GB, cần ~5 GB trống)", variant="primary", ) gpu_install_log = gr.Textbox( label="Tiến trình cài đặt", value="", interactive=False, lines=6, visible=False, elem_classes=["status-box"], ) with gr.Accordion("🧪 Tuỳ chọn chuẩn hóa xưng hô (nâng cao · thử nghiệm)", open=False, elem_id="honorific-accordion"): with gr.Row(): honorific_kinship = gr.Checkbox( value=False, label="Thân tộc (tỷ / muội / ca ca…)", scale=1, ) honorific_pronouns = gr.Checkbox( value=False, label="Đại từ (ngươi / hắn / nàng / ta)", scale=1, ) pronoun_harmonizer_v9 = gr.Checkbox( value=False, label="Ổn định ngôi hiện đại", scale=1, ) gr.Markdown( "🧪 Thử nghiệm Hậu kỳ chỉnh xưng hô " "ngoài model — bản dịch gốc thường đã ổn, đây là lớp tinh chỉnh tuỳ chọn " "nên **mặc định tắt**; bật khi muốn ép phong cách, và nên rà lại kết quả.\n\n" "Ép xưng hô về Hán-Việt khi nguồn có từ tương ứng · **Đại từ** chỉ áp " "khi route cấp chương là văn cổ trang · **Ổn định ngôi hiện đại** chỉ " "rewrite khi route hiện đại, ví dụ thầy/em, mẹ/con, anh/em. Mixed/unknown " "sẽ guard để tránh sửa quá tay.", elem_classes=["honorific-hint"], ) with gr.Accordion("📚 Tên riêng & thuật ngữ (glossary)", open=False, elem_id="glossary-accordion"): gr.Markdown( "Glossary thay đúng `source_zh` bằng **placeholder đã kiểm định** trước khi " "Marian dịch, rồi khôi phục thành `target_vi`; vì vậy tên hiếm không phụ " "thuộc vào cách model tự phiên âm. Nếu placeholder bị mất/đổi/lặp, dòng đó " "được dịch lại từ nguồn gốc và mới dùng alias làm fallback. " "`Loại` chỉ để quản lý và **không bắt buộc**.", elem_classes=["honorific-hint"], ) glossary_table = gr.Dataframe( value=[["", "", "", "", True]], headers=[ "Nguồn Trung *", "Đích Việt *", "Loại (tùy chọn)", "Alias fallback (tùy chọn, ngăn bằng |)", "Bật", ], datatype=["str", "str", "str", "str", "bool"], type="array", row_count=3, column_count=5, column_widths=["20%", "22%", "16%", "32%", "10%"], max_height=360, interactive=True, show_row_numbers=True, label="Glossary của phiên hiện tại", elem_id="glossary-table", ) with gr.Row(): glossary_add_row_btn = gr.Button( "+ Thêm hàng", variant="secondary", size="sm", scale=0, min_width=140, elem_id="glossary-add-row", ) with gr.Row(): glossary_file = gr.File( label="Nhập glossary (.tsv / .json)", file_types=[".tsv", ".json"], type="filepath", scale=3, ) glossary_format = gr.Radio( [("TSV", "tsv"), ("JSON", "json")], value="tsv", label="Định dạng xuất", scale=1, ) with gr.Row(): glossary_import_btn = gr.Button("Nạp vào bảng", variant="secondary") glossary_export_btn = gr.Button("Xuất glossary", variant="secondary") glossary_download = gr.File(label="Tải glossary") glossary_status = gr.Markdown() if IS_HF_SPACE: with gr.Accordion("🚀 Dùng nhanh/mạnh hơn — Google Colab/Kaggle (GPU) hoặc cài máy (offline)", open=False): gr.Markdown( f"""Bản Space này chạy **CPU** nên hợp dịch thử. Ba cách dùng nhanh/mạnh hơn: ### ☁️ Cách 1 — Chạy trên Google Colab (CPU/GPU miễn phí, không cài gì) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)]({COLAB_URL}) Bấm nút trên → đăng nhập Google → `Runtime → Run all` → mở link `*.gradio.live` hiện ra để dịch. Muốn nhanh: chọn `Runtime → Change runtime type → T4 GPU` trước (Colab tiếng Việt: `Thời gian chạy → Thay đổi loại thời gian chạy → T4 GPU → Lưu`). ⚠️ Chọn **T4 GPU**, KHÔNG chọn **TPU** — app chỉ chạy GPU NVIDIA/CPU, không dùng được TPU. > ⚡ **Tốc độ trên Colab T4** (đo thật, notebook đã tối ưu window): **~40.000 chữ Hán/giây** > ở beam 1 hoặc **~28.000 chữ/giây** ở beam 2; cả một bộ truyện dài **2,4 triệu chữ > khoảng 1–1,5 phút**. Nhanh hơn CPU nhiều lần. ### ☁️ Cách 2 — Chạy trên Kaggle (T4×2 miễn phí, hợp dịch file dài) [![Open in Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)]({KAGGLE_NOTEBOOK_URL}) Bấm nút trên → đăng nhập Kaggle → bấm **Create / Edit** (Kaggle tạo notebook từ GitHub) → bật `Settings → Accelerator → GPU T4 x2` và `Settings → Internet → on` → `Run All`. Cell cuối hiện link `*.gradio.live` để mở giao diện dịch. Notebook đã bật `HACHIMIMT_AUTO_ALL_GPUS=1` và window tối ưu để dùng cả 2 GPU T4 khi Kaggle cấp đủ. > ⚡ **Tốc độ trên T4 x2** (đo mới nhất): **~81.000 chữ Hán/giây** (beam 1) hoặc > **~54.000 chữ Hán/giây** (beam 2) — file **2,84 triệu chữ ~35 giây** ở beam 1, > hoặc **~52 giây** ở beam 2. Nhanh hơn Colab T4 x1 beam 2 khoảng 1,9× trên file dài. ### 💻 Cách 3 — Cài bản chạy trên máy (offline, dịch file lớn) **1. Tải về** — [⬇ Tải bản cài (.zip, ~60 KB)]({LOCAL_ZIP_URL}) rồi giải nén (ra thư mục `hachimimt/`). **2. Windows** (cần [Python 3.10+](https://www.python.org/downloads/)): trong thư mục `hachimimt`, chạy `setup.bat` (cài thư viện + tải model mặc định, một lần) rồi `start.bat` — trình duyệt tự mở `http://127.0.0.1:7860`. **3. macOS / Linux**: `./setup_macos.sh` rồi `./start_macos.sh` (xem `README.md`). - **GPU NVIDIA**: app mặc định chạy CPU; có nút **“Cài torch để bật GPU”** ngay trong app (tải ~2–3 GB, một lần) → nhanh hơn nhiều lần với văn bản dài. - Các model tự tải từ Hugging Face lần đầu, sau đó chạy **offline**. - Bản local mở khoá: chọn 8 model, dịch file `.txt`, không giới hạn CPU như Space.""", elem_classes=["info-card"], ) with gr.Accordion("Thông tin máy & hướng dẫn", open=False): _startup_line = ( "" # Space: không có start.bat / 127.0.0.1 → bỏ dòng vô nghĩa if IS_HF_SPACE else f"\n\n**Khởi động / tắt:** `start.bat` / `stop.bat` · http://127.0.0.1:{APP_PORT}" ) gr.Markdown( f"**Cấu hình tự động:** {HW_PROFILE.summary}\n\n" f"**GPU inference:** {HW_PROFILE.gpu_name or 'CPU'}" f"{_startup_line}", elem_classes=["info-card"], ) model_select.change( on_model_change, inputs=[model_select, backend_select], outputs=[beam_size, model_badge], ) backend_select.change( on_backend_change, inputs=[backend_select, model_select], outputs=[engine_hint, model_badge], ) auto_batch.change(on_auto_batch_toggle, inputs=[auto_batch], outputs=[manual_batch]) glossary_add_row_event = glossary_add_row_btn.click( add_glossary_row_ui, inputs=[glossary_table], outputs=[glossary_table], queue=False, ) glossary_add_row_event.then( fn=None, js="() => window.__hachimimtRefreshGlossaryTable?.()", queue=False, api_name=False, ) glossary_import_btn.click( import_glossary_ui, inputs=[glossary_file], outputs=[glossary_table, glossary_status], queue=False, ) glossary_export_btn.click( export_glossary_ui, inputs=[glossary_table, glossary_format], outputs=[glossary_download, glossary_status], queue=False, ) gpu_install_btn.click( lambda: gr.update(visible=True), outputs=[gpu_install_log], ).then( install_gpu_torch_ui, outputs=[gpu_install_btn, gpu_install_log], ) # ── Nhập liệu (văn bản / file) ──────────────────────────────── with gr.Tabs(): with gr.Tab("📝 Dịch văn bản"): source = gr.Textbox( label="Văn bản gốc (Tiếng Trung)", placeholder="粘贴中文原文…", lines=10, elem_classes=["source-input"], ) text_btn = gr.Button("Dịch văn bản", variant="primary", size="lg") with gr.Tab("📄 Dịch file .txt"): gr.Markdown("Upload file `.txt` tiếng Trung → dịch toàn bộ → tải file `.txt` tiếng Việt.") file_input = gr.File(label="Chọn file .txt đầu vào", file_types=[".txt"], type="filepath") file_preview = gr.Textbox( label="Xem trước nội dung file", lines=8, interactive=False, elem_classes=["file-preview"], ) file_btn = gr.Button("Dịch file & xuất .txt", variant="primary", size="lg") def preview_file(file_obj) -> str: if not file_obj: return "" return build_file_preview(read_text_file(Path(file_obj)), max_chars=8000) file_input.change(preview_file, inputs=[file_input], outputs=[file_preview]) # ── Tiến trình (một thanh duy nhất) ─────────────────────────── progress_html = gr.HTML(render_progress_html(0, "Sẵn sàng.", False)) progress_active_state = gr.State(False) # ── Kết quả ─────────────────────────────────────────────────── result_summary = gr.Markdown(elem_id="result-summary") with gr.Accordion("Đối chiếu song song (bản gốc · bản dịch)", open=True): compare_view = gr.HTML(render_compare_html([]), elem_id="compare-view") with gr.Accordion("Bản dịch đầy đủ & xuất file", open=True): full_output = gr.Textbox( label="Bản dịch đầy đủ", lines=10, interactive=False, elem_id="full-output", ) # Giữ bản dịch ĐẦY ĐỦ (không bị cap hiển thị) để nút Xuất .txt dùng. full_text_state = gr.State("") # Snapshot ngữ cảnh bản dịch cho panel feedback (luôn tạo, kể cả khi # panel ẩn — để result_outputs khớp 7-tuple của _build_results). feedback_context_state = gr.State({}) with gr.Row(): export_btn = gr.Button("💾 Xuất bản dịch .txt", variant="secondary") download_file = gr.File(label="Tải file bản dịch (.txt)") if feedback_panel_enabled(DEV_FEEDBACK, IS_HF_SPACE): with gr.Accordion("📝 Góp ý / sửa câu dịch (dev)", open=False): fb_idx = gr.Number(label="Số thứ tự câu", precision=0, value=1) fb_source = gr.Textbox(label="Câu gốc (Trung)", interactive=False, lines=2) fb_mt = gr.Textbox(label="Bản dịch hiện tại", interactive=False, lines=2) fb_edit = gr.Textbox(label="Bản sửa của bạn", lines=2) fb_category = gr.Dropdown( label="Loại lỗi (tùy chọn)", choices=["name", "pronoun", "punctuation", "line_break", "missing", "hallucination", "style", "other"], value=None, allow_custom_value=False, ) with gr.Row(): fb_good = gr.Button("👍 Tốt") fb_bad = gr.Button("👎 Chưa đạt") fb_save = gr.Button("💾 Lưu bản sửa", variant="primary") fb_next = gr.Button("⏭ Lưu & câu tiếp") fb_status = gr.Markdown() fb_idx.change( _load_sentence, inputs=[fb_idx, feedback_context_state], outputs=[fb_source, fb_mt, fb_edit, fb_status], ) fb_good.click( lambda idx, ctx: _save_feedback(idx, "", None, "good", ctx), inputs=[fb_idx, feedback_context_state], outputs=[fb_status], ) fb_bad.click( lambda idx, cat, ctx: _save_feedback(idx, "", cat, "bad", ctx), inputs=[fb_idx, fb_category, feedback_context_state], outputs=[fb_status], ) fb_save.click( lambda idx, corr, cat, ctx: _save_feedback(idx, corr, cat, "bad", ctx, require_correction=True), inputs=[fb_idx, fb_edit, fb_category, feedback_context_state], outputs=[fb_status], ) fb_next.click( _save_and_next, inputs=[fb_idx, fb_edit, fb_category, feedback_context_state], outputs=[fb_idx, fb_source, fb_mt, fb_edit, fb_status], ) result_outputs = build_result_outputs([ compare_view, full_output, status, download_file, result_summary, full_text_state, ], feedback_context_state) translate_outputs = [progress_html, progress_active_state, *result_outputs] translate_inputs = [ model_select, backend_select, beam_size, chunk_mode, normalize_mode, honorific_kinship, honorific_pronouns, pronoun_harmonizer_v9, auto_batch, manual_batch, glossary_table, ] text_event = text_btn.click( prepare_text_progress_ui, inputs=[source], outputs=[progress_html, progress_active_state], scroll_to_output=True, show_progress="hidden", queue=False, ) text_event.then( translate_text_ui, inputs=[source, *translate_inputs], outputs=translate_outputs, scroll_to_output=True, show_progress="hidden", concurrency_limit=1, concurrency_id="translate", ) file_event = file_btn.click( prepare_file_progress_ui, inputs=[file_input], outputs=[progress_html, progress_active_state], scroll_to_output=True, show_progress="hidden", queue=False, ) file_event.then( translate_file_ui, inputs=[file_input, *translate_inputs], outputs=translate_outputs, scroll_to_output=True, show_progress="hidden", concurrency_limit=1, concurrency_id="translate", ) export_btn.click( export_translation_ui, inputs=[full_text_state], outputs=[download_file], ) progress_timer = gr.Timer(0.5, active=True) progress_timer.tick( poll_progress_ui, inputs=[progress_active_state], outputs=[progress_html], show_progress=False, queue=False, ) return demo def main() -> None: if not IS_HF_SPACE: # Space: process do HF quản, không cần PID write_pid_file() atexit.register(remove_pid_file) else: # Trên Space: cache + warmup model mặc định (HachimiMT-60 CT2) lúc KHỞI ĐỘNG # (startup runtime, trong main() — KHÔNG phải build-time) → user mở lên dịch # được NGAY, không chờ tải lần đầu. Hiệu quả vì Space giữ process chạy lâu. # Lỗi (mạng) KHÔNG chặn app — fallback lazy-download khi dịch. try: print(f"[Space] cpu_count={os.cpu_count()} threads={os.environ.get('HACHIMIMT_THREADS')} " f"batch={os.environ.get('HACHIMIMT_BATCH_SIZE')} — cache + warmup {DEFAULT_MODEL_KEY}...", flush=True) ensure_model_files(MODELS[DEFAULT_MODEL_KEY], Backend.CT2) # load() đã tự warmup (dịch 1 câu) → user dịch là nhanh ngay. translator.load(DEFAULT_MODEL_KEY, Backend.CT2) print(f"[Space] Đã cache + warmup {DEFAULT_MODEL_KEY}.", flush=True) except Exception as exc: # noqa: BLE001 — preload best-effort, không fatal print(f"[Space] Preload/warmup lỗi (sẽ lazy khi dịch): {exc}", flush=True) reset_progress() demo = build_ui() demo.queue(default_concurrency_limit=8) # Gradio 6: theme/css/head truyền ở launch() (không còn ở Blocks()). favicon = Path(__file__).resolve().parent / "assets" / "favicon.svg" launch_kwargs = dict( theme=gr.themes.Soft(primary_hue="orange", neutral_hue="stone"), css=CUSTOM_CSS, head=HEAD_HTML, favicon_path=str(favicon) if favicon.exists() else None, allowed_paths=[str(EXPORTS_DIR), tempfile.gettempdir()], ) # HACHIMIMT_SHARE=1 (Google Colab / máy từ xa): tạo link share công khai # (gradio.live), bind 0.0.0.0 để truy cập từ ngoài. share = os.environ.get("HACHIMIMT_SHARE", "").strip() == "1" if share: launch_kwargs["share"] = True launch_kwargs["server_name"] = "0.0.0.0" elif not IS_HF_SPACE: # local: bind localhost cố định; Space: HF tự lo launch_kwargs["server_name"] = "127.0.0.1" launch_kwargs["server_port"] = APP_PORT demo.launch(**launch_kwargs) if __name__ == "__main__": main()