HachimiMT-demo / src /app.py
ngocdang83's picture
docs: add measured Kaggle beam1 speed
f5c91b0 verified
Raw
History Blame
72 kB
"""Gradio UI for HachimiMT zh→vi translation."""
from __future__ import annotations
import atexit
import html
import os
import tempfile
import time
import unicodedata
from datetime import datetime
from pathlib import Path
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
import gradio as gr
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
# 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")
# 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=<github blob url> → 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
# 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")
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 (
'<div class="gpu-hint">'
f"⚡ Phát hiện <b>{gpu}</b> nhưng app đang chạy bằng <b>CPU</b> "
"(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)."
"</div>"
)
# 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 <head> để giảm nhấp nháy khi tải.
HEAD_HTML = """
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
"""
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 <body> 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;
/* 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: <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 {
height: 100%; border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--gold));
background-size: 200% 100%;
transition: width 0.3s ease;
}
.progress-fill.is-running { animation: progress-shine 1.6s linear infinite; }
@keyframes progress-shine { to { background-position: 200% 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'<a class="model-hf-link" href="{url}" target="_blank" rel="noopener">'
"↗ Trang Hugging Face</a>"
)
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 = '<span class="model-badge ready">✓ Đã tải — dịch được ngay</span>'
return f'<div class="model-meta">{badge}{link}</div>'
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'<span class="model-badge pending">⬇ Chưa có{size} — '
"sẽ tự tải từ Hugging Face ở lần dịch đầu</span>"
)
return f'<div class="model-meta">{badge}{link}</div>'
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"""
<div class="progress-wrap">
<div class="progress-head">
<span class="progress-pct">{pct:.0f}%</span>
<span class="progress-state">{safe_status}</span>
</div>
<div class="progress-track">
<div class="progress-fill{running_cls}" style="width: {pct:.1f}%;"></div>
</div>
<div class="progress-msg">{safe_message}</div>
</div>
"""
def poll_progress_ui() -> str:
state = snapshot()
return render_progress_html(state.pct, state.message, state.running)
EMPTY_COMPARE_HTML = (
'<div class="compare-empty">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.</div>"
)
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 = [
'<div class="cmp-head">'
'<span class="cmp-num"></span>'
'<div class="cmp-col">Tiếng Trung</div>'
'<div class="cmp-col">Tiếng Việt</div>'
"</div>"
]
for idx, zh, vi in display:
safe_zh = html.escape(zh, quote=True)
safe_vi = html.escape(vi, quote=True)
items.append(
f'<div class="cmp-row">'
f'<span class="cmp-num">{idx}</span>'
f'<p class="cmp-zh">{safe_zh}</p>'
f'<p class="cmp-vi">{safe_vi}</p>'
f"</div>"
)
note = ""
if len(rows) > MAX_TABLE_ROWS:
note = (
f'<div class="cmp-note">Hiển thị {MAX_TABLE_ROWS}/{len(rows)} đoạn đầu. '
"Xem bản dịch đầy đủ ở khối bên dưới.</div>"
)
return f'<div class="compare-list">{"".join(items)}{note}</div>'
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 _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:
continue
candidates.append((_decoded_text_score(decoded), index, 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
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 export_translation(full_text: str, filename_stem: str) -> 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"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out_path = EXPORTS_DIR / f"{safe_stem}_vi_{timestamp}.txt"
out_path.write_text(full_text, encoding="utf-8")
return str(out_path)
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)
return (
f"{head}\n\n"
f"────────────────────\n"
f"[Đã ẩn ~{omitted:,} 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.]"
).replace(",", ".")
def _build_results(
rows: list[tuple[int, str, str]],
full_text: str,
status: str,
summary: str,
download_path: str | None,
) -> 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
)
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]:
raw_rows = list(rows)
raw_full_text = "\n".join(vi for _, _, vi in raw_rows)
try:
return _apply_postprocess_rows(
raw_rows,
honorific_kinship=honorific_kinship,
honorific_pronouns=honorific_pronouns,
pronoun_harmonizer_v9=pronoun_harmonizer_v9,
)
except Exception as exc:
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,
*,
filename_stem: str,
summary_prefix: str,
progress: gr.Progress = gr.Progress(),
) -> 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)
honorific_kinship = bool(honorific_kinship)
honorific_pronouns = bool(honorific_pronouns)
honorific_on = honorific_kinship or honorific_pronouns
pronoun_harmonizer_v9 = bool(pronoun_harmonizer_v9)
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)
progress(0, desc=load_msg)
resolve_batch_size(auto_batch, manual_batch)
status = ensure_model(model_key, backend, beam_size)
set_progress(2, f"{normalize_msg} Đang chia chunk...")
progress(0.02, desc="Đang chia chunk...")
rows: list[tuple[int, str, str]] = []
full_text = ""
last_progress_update = 0.0
translate_start = time.perf_counter()
for done, total, message, result_rows, result_text in translator.translate_text_iter(
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)
progress(done / max(total, 1), desc=message)
last_progress_update = now
translate_seconds = time.perf_counter() - translate_start
rows, full_text, postprocess_report = apply_postprocess_rows(
rows,
honorific_kinship=honorific_kinship,
honorific_pronouns=honorific_pronouns,
pronoun_harmonizer_v9=pronoun_harmonizer_v9,
)
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 "")
# 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)
rate = len(rows) / translate_seconds if translate_seconds > 0 else 0.0
time_note = f"⏱ {duration}"
if rate >= 1:
time_note += f" ({rate:.0f} chunk/giây)"
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 ""
summary = f"{summary_prefix} **{len(rows)}** chunk · {time_note}. {normalize_msg}{route_note}{honorific_note}{pronoun_note}{warning_note}{space_cap_notice}"
status = f"{status} · {time_note}"
finish_progress(f"Hoàn tất — {len(rows)} chunk trong {duration} (100%)")
progress(1.0, desc="Hoàn tất")
return _build_results(rows, full_text, status, summary, download_path)
except Exception as exc:
reset_progress(f"Lỗi: {_exception_message(exc)}")
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,
progress: gr.Progress = gr.Progress(),
) -> tuple:
if not source.strip():
raise gr.Error("Nhập văn bản tiếng Trung cần dịch.")
return _translate_run(
source,
model_key,
backend,
beam_size,
chunk_mode,
normalize_mode,
honorific_kinship,
honorific_pronouns,
pronoun_harmonizer_v9,
auto_batch,
manual_batch,
filename_stem="hachimimt",
summary_prefix="Đã dịch",
progress=progress,
)
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,
progress: gr.Progress = gr.Progress(),
) -> 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.")
return _translate_run(
source,
model_key,
backend,
beam_size,
chunk_mode,
normalize_mode,
honorific_kinship,
honorific_pronouns,
pronoun_harmonizer_v9,
auto_batch,
manual_batch,
filename_stem=path.stem,
summary_prefix=f"Đã dịch từ `{path.name}` —",
progress=progress,
)
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('<div id="app-rule"></div>')
# ── Banner demo (chỉ trên Space) ──────────────────────────────
if IS_HF_SPACE:
gr.HTML(
'<div id="demo-banner">'
'Đây là <b>bản demo chạy CPU</b> — phù hợp dịch thử đoạn ngắn. '
'Cần <b>GPU</b>, dịch <b>file/chương dài</b> hay chạy <b>offline</b>? '
f'<a href="{COLAB_URL}" target="_blank" rel="noopener">☁️ chạy trên Google Colab</a> '
f'/ <a href="{KAGGLE_NOTEBOOK_URL}" target="_blank" rel="noopener">Kaggle T4×2</a> '
f'hoặc <a href="{LOCAL_ZIP_URL}">⬇ tải bản cài máy</a> '
'(chi tiết ở mục “Dùng nhanh/mạnh hơn” cuối trang).'
"</div>"
)
# ── 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 cho "
"nhiều ngữ cảnh hơn (giữ xuống dòng, ranh giới dòng có thể xê dịch nhẹ)",
)
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(
"<span class=\"exp-badge\">🧪 Thử nghiệm</span> 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"],
)
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` để dùng cả 2 GPU T4 khi Kaggle cấp đủ.
> ⚡ **Tốc độ trên T4 x2** (đo mới nhất): **~71.000 chữ Hán/giây** (beam 1) hoặc
> **~52.000 chữ Hán/giây** (beam 2) — file **2,84 triệu chữ ~40 giây** ở beam 1,
> hoặc **~55 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.
- Model (HachimiMT/MoxhiMT) tự tải từ Hugging Face lần đầu, sau đó chạy **offline**.
- Bản local mở khoá: chọn 4 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])
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 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))
# ── 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("")
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)")
result_outputs = [
compare_view,
full_output,
status,
download_file,
result_summary,
full_text_state,
]
translate_inputs = [
model_select, backend_select, beam_size, chunk_mode, normalize_mode,
honorific_kinship, honorific_pronouns, pronoun_harmonizer_v9,
auto_batch, manual_batch,
]
text_btn.click(
translate_text_ui,
inputs=[source, *translate_inputs],
outputs=result_outputs,
concurrency_limit=1,
concurrency_id="translate",
)
file_btn.click(
translate_file_ui,
inputs=[file_input, *translate_inputs],
outputs=result_outputs,
concurrency_limit=1,
concurrency_id="translate",
)
export_btn.click(
lambda text: export_translation(text, "hachimimt"),
inputs=[full_text_state], outputs=[download_file],
)
progress_timer = gr.Timer(0.3, active=True)
progress_timer.tick(
poll_progress_ui,
outputs=[progress_html],
show_progress=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()