Spaces:
Running
Running
Deploy session-scoped glossary (GitHub 26806a5)
Browse files- README.md +2 -8
- README_hachimimt_colab.md +6 -3
- hachimimt-local.zip +2 -2
- src/app.py +293 -87
- src/glossary.py +351 -0
README.md
CHANGED
|
@@ -13,6 +13,8 @@ short_description: Dịch truyện Trung → Việt
|
|
| 13 |
# HachimiMT — Dịch Trung Việt
|
| 14 |
|
| 15 |
Space này là bản demo CPU của HachimiMT/MoxhiMT/HirashibaMT để dịch thử đoạn ngắn.
|
|
|
|
|
|
|
| 16 |
Vì chạy trên CPU dùng chung, nếu muốn dịch file dài hoặc nguyên bộ truyện, hãy dùng
|
| 17 |
notebook GPU hoặc bản local:
|
| 18 |
|
|
@@ -21,11 +23,3 @@ notebook GPU hoặc bản local:
|
|
| 21 |
- Bản local Windows/macOS/Linux: tải trong giao diện Space, chạy được offline sau khi tải model.
|
| 22 |
|
| 23 |
Mã nguồn chính: https://github.com/ngocdang8311/qt2
|
| 24 |
-
|
| 25 |
-
## Model revisions deployed
|
| 26 |
-
|
| 27 |
-
- `DanVP/MoxhiMT-30`: tokenizer hotfix `v4.0.1` (`d0a8923e`).
|
| 28 |
-
- `DanVP/MoxhiMT-60`: tokenizer hotfix (`3ae60c79`).
|
| 29 |
-
- `DanVP/MoxhiMT-30-QT`: tokenizer hotfix (`4f144362`).
|
| 30 |
-
|
| 31 |
-
Updated 2026-07-14. A cold Space deployment fetches these current Hub revisions.
|
|
|
|
| 13 |
# HachimiMT — Dịch Trung Việt
|
| 14 |
|
| 15 |
Space này là bản demo CPU của HachimiMT/MoxhiMT/HirashibaMT để dịch thử đoạn ngắn.
|
| 16 |
+
Giao diện có glossary theo phiên để chuẩn hóa alias tên riêng/thuật ngữ và cho phép
|
| 17 |
+
nhập/xuất TSV hoặc JSON.
|
| 18 |
Vì chạy trên CPU dùng chung, nếu muốn dịch file dài hoặc nguyên bộ truyện, hãy dùng
|
| 19 |
notebook GPU hoặc bản local:
|
| 20 |
|
|
|
|
| 23 |
- Bản local Windows/macOS/Linux: tải trong giao diện Space, chạy được offline sau khi tải model.
|
| 24 |
|
| 25 |
Mã nguồn chính: https://github.com/ngocdang8311/qt2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README_hachimimt_colab.md
CHANGED
|
@@ -159,6 +159,8 @@ Repo mô hình trên Hugging Face:
|
|
| 159 |
- 📄 **Dán văn bản hoặc tải file `.txt`** (tự nhận mã GB18030 / Big5 / UTF-8, hỗ
|
| 160 |
trợ cả phồn thể lẫn giản thể).
|
| 161 |
- ⚙️ **Chọn mô hình** và **beam** (đánh đổi tốc độ ↔ chất lượng).
|
|
|
|
|
|
|
| 162 |
- 🈶 **Chuẩn hóa xưng hô Hán-Việt** (tùy chọn nâng cao, thử nghiệm): chuyển 哥哥/姐姐…
|
| 163 |
sang ca ca / tỷ tỷ… theo văn phong tiên hiệp, nhận diện bối cảnh cổ trang vs hiện
|
| 164 |
đại. Mặc định tắt — bật khi cần.
|
|
@@ -173,11 +175,12 @@ Repo mô hình trên Hugging Face:
|
|
| 173 |
|
| 174 |
- **Tên riêng hiếm có thể dịch lệch giữa các đoạn.** Mô hình nhỏ nên một số tên ít
|
| 175 |
gặp có thể ra vài biến thể Hán-Việt khác nhau trong cùng truyện. Đây chính là chỗ
|
| 176 |
-
|
| 177 |
-
|
| 178 |
- **Cần GPU để nhanh.** CPU (bản máy / demo HF) chậm hơn nhiều lần; dịch nguyên bộ
|
| 179 |
truyện trên CPU không thực tế.
|
| 180 |
-
- **
|
|
|
|
| 181 |
- Mô hình nhỏ → đôi khi vẫn có câu khó hiểu ở đoạn quá dài hoặc nội dung hiếm gặp.
|
| 182 |
|
| 183 |
Nếu những điểm trên là then chốt với bạn (đọc offline tuyệt đối, kiểm soát chặt
|
|
|
|
| 159 |
- 📄 **Dán văn bản hoặc tải file `.txt`** (tự nhận mã GB18030 / Big5 / UTF-8, hỗ
|
| 160 |
trợ cả phồn thể lẫn giản thể).
|
| 161 |
- ⚙️ **Chọn mô hình** và **beam** (đánh đổi tốc độ ↔ chất lượng).
|
| 162 |
+
- 📚 **Glossary tên riêng/thuật ngữ**: nhập cặp Trung → Việt, alias tùy chọn, hỗ
|
| 163 |
+
trợ nhập/xuất TSV/JSON; cột loại entity không bắt buộc.
|
| 164 |
- 🈶 **Chuẩn hóa xưng hô Hán-Việt** (tùy chọn nâng cao, thử nghiệm): chuyển 哥哥/姐姐…
|
| 165 |
sang ca ca / tỷ tỷ… theo văn phong tiên hiệp, nhận diện bối cảnh cổ trang vs hiện
|
| 166 |
đại. Mặc định tắt — bật khi cần.
|
|
|
|
| 175 |
|
| 176 |
- **Tên riêng hiếm có thể dịch lệch giữa các đoạn.** Mô hình nhỏ nên một số tên ít
|
| 177 |
gặp có thể ra vài biến thể Hán-Việt khác nhau trong cùng truyện. Đây chính là chỗ
|
| 178 |
+
glossary hỗ trợ chuẩn hóa các alias đã biết. Bản glossary an toàn hiện không tự
|
| 179 |
+
chèn tên nếu mô hình bỏ hẳn entity.
|
| 180 |
- **Cần GPU để nhanh.** CPU (bản máy / demo HF) chậm hơn nhiều lần; dịch nguyên bộ
|
| 181 |
truyện trên CPU không thực tế.
|
| 182 |
+
- **Glossary chưa phải constrained decoding**: nó chuẩn hóa alias sau dịch thay vì
|
| 183 |
+
ép model sinh một entity bằng placeholder.
|
| 184 |
- Mô hình nhỏ → đôi khi vẫn có câu khó hiểu ở đoạn quá dài hoặc nội dung hiếm gặp.
|
| 185 |
|
| 186 |
Nếu những điểm trên là then chốt với bạn (đọc offline tuyệt đối, kiểm soát chặt
|
hachimimt-local.zip
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7765446cc8ea375a105e4127d4e129dcf7745c31d7362ae9e399170b5b02a6c3
|
| 3 |
+
size 135912
|
src/app.py
CHANGED
|
@@ -2,10 +2,11 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
import atexit
|
| 6 |
-
import
|
| 7 |
-
import
|
| 8 |
-
import
|
|
|
|
| 9 |
import tempfile
|
| 10 |
import time
|
| 11 |
import unicodedata
|
|
@@ -43,11 +44,19 @@ def _patch_windows_platform() -> None:
|
|
| 43 |
|
| 44 |
_patch_windows_platform()
|
| 45 |
|
| 46 |
-
import gradio as gr
|
| 47 |
-
|
| 48 |
-
import
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
from text_preprocess import (
|
| 52 |
NORMALIZE_AUTO,
|
| 53 |
NORMALIZE_NONE,
|
|
@@ -79,9 +88,11 @@ def _env_float(name: str, default: float, *, min_value: float = 0.0, max_value:
|
|
| 79 |
|
| 80 |
|
| 81 |
ROOT = Path(__file__).resolve().parent.parent
|
| 82 |
-
EXPORTS_DIR = ROOT / "exports"
|
| 83 |
-
PID_FILE = ROOT / ".hachimimt.pid"
|
| 84 |
-
APP_PORT = 7860
|
|
|
|
|
|
|
| 85 |
# HF Space tự set SPACE_ID. Khi ở Space: CPU-only (ẩn nút cài GPU/torch), process
|
| 86 |
# do HF quản (bỏ PID file), HF tự lo host/port (không ép 127.0.0.1). App chạy
|
| 87 |
# local KHÔNG đổi gì.
|
|
@@ -1233,23 +1244,121 @@ def cap_input_for_space(source: str) -> tuple[str, str]:
|
|
| 1233 |
return capped, notice
|
| 1234 |
|
| 1235 |
|
| 1236 |
-
def _exception_message(exc: Exception) -> str:
|
| 1237 |
-
return str(exc).strip() or exc.__class__.__name__
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
-
def
|
| 1241 |
-
|
| 1242 |
-
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1253 |
if seconds < 60:
|
| 1254 |
return f"{seconds:.1f} giây".replace(".", ",")
|
| 1255 |
minutes = int(seconds // 60)
|
|
@@ -1489,9 +1598,9 @@ def _apply_postprocess_rows(
|
|
| 1489 |
}
|
| 1490 |
|
| 1491 |
|
| 1492 |
-
def _translate_run(
|
| 1493 |
-
source: str,
|
| 1494 |
-
model_key: str,
|
| 1495 |
backend: str,
|
| 1496 |
beam_size: float,
|
| 1497 |
chunk_mode: str,
|
|
@@ -1499,21 +1608,29 @@ def _translate_run(
|
|
| 1499 |
honorific_kinship: bool,
|
| 1500 |
honorific_pronouns: bool,
|
| 1501 |
pronoun_harmonizer_v9: bool,
|
| 1502 |
-
auto_batch: bool,
|
| 1503 |
-
manual_batch: float,
|
| 1504 |
-
*,
|
| 1505 |
-
|
| 1506 |
-
|
| 1507 |
-
|
|
|
|
| 1508 |
session: str | None = None,
|
| 1509 |
) -> Iterator[tuple]:
|
| 1510 |
try:
|
| 1511 |
source, space_cap_notice = cap_input_for_space(source)
|
| 1512 |
-
original_source = source
|
| 1513 |
-
source = normalize_chinese_text(source, normalize_mode)
|
| 1514 |
-
normalize_msg = normalization_message(original_source, source, normalize_mode)
|
| 1515 |
-
|
| 1516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1517 |
honorific_on = honorific_kinship or honorific_pronouns
|
| 1518 |
pronoun_harmonizer_v9 = bool(pronoun_harmonizer_v9)
|
| 1519 |
|
|
@@ -1573,13 +1690,26 @@ def _translate_run(
|
|
| 1573 |
source_text=source,
|
| 1574 |
chunk_mode=chunk_mode,
|
| 1575 |
)
|
| 1576 |
-
genre_decision = postprocess_report.get("genre_decision")
|
| 1577 |
-
honorific_changed = int(postprocess_report.get("honorific_changed") or 0)
|
| 1578 |
-
honorific_pronouns_effective = bool(postprocess_report.get("honorific_pronouns_effective"))
|
| 1579 |
-
pronoun_report = postprocess_report.get("pronoun_report") or {}
|
| 1580 |
-
postprocess_warning = str(postprocess_report.get("warning") or "")
|
| 1581 |
-
|
| 1582 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1583 |
# khi user bấm nút "Xuất bản dịch .txt" (export_btn.click). Bản dịch đầy đủ
|
| 1584 |
# vẫn nằm trong ô + full_text_state để xuất khi cần.
|
| 1585 |
download_path = None
|
|
@@ -1616,8 +1746,21 @@ def _translate_run(
|
|
| 1616 |
f"route `{pronoun_report.get('route', 'n/a')}`."
|
| 1617 |
)
|
| 1618 |
route_note = f" Route hậu kỳ `{genre_decision.route}`." if genre_decision and (honorific_on or pronoun_harmonizer_v9) else ""
|
| 1619 |
-
warning_note = f" Hậu kỳ lỗi, đã giữ bản dịch thô: {postprocess_warning}." if postprocess_warning else ""
|
| 1620 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1621 |
status = f"{status} · {time_note}"
|
| 1622 |
finish_progress(
|
| 1623 |
f"Hoàn tất — {chunk_count} chunk trong {duration} (100%)",
|
|
@@ -1645,7 +1788,7 @@ def _translate_run(
|
|
| 1645 |
raise
|
| 1646 |
|
| 1647 |
|
| 1648 |
-
def translate_text_ui(
|
| 1649 |
source: str,
|
| 1650 |
model_key: str,
|
| 1651 |
backend: str,
|
|
@@ -1655,10 +1798,11 @@ def translate_text_ui(
|
|
| 1655 |
honorific_kinship: bool,
|
| 1656 |
honorific_pronouns: bool,
|
| 1657 |
pronoun_harmonizer_v9: bool,
|
| 1658 |
-
auto_batch: bool,
|
| 1659 |
-
manual_batch: float,
|
| 1660 |
-
|
| 1661 |
-
|
|
|
|
| 1662 |
if not source.strip():
|
| 1663 |
raise gr.Error("Nhập văn bản tiếng Trung cần dịch.")
|
| 1664 |
|
|
@@ -1672,16 +1816,17 @@ def translate_text_ui(
|
|
| 1672 |
honorific_kinship,
|
| 1673 |
honorific_pronouns,
|
| 1674 |
pronoun_harmonizer_v9,
|
| 1675 |
-
auto_batch,
|
| 1676 |
-
manual_batch,
|
| 1677 |
-
|
|
|
|
| 1678 |
summary_prefix="Đã dịch",
|
| 1679 |
source_kind="text",
|
| 1680 |
session=_session_key(request),
|
| 1681 |
)
|
| 1682 |
|
| 1683 |
|
| 1684 |
-
def translate_file_ui(
|
| 1685 |
file_obj,
|
| 1686 |
model_key: str,
|
| 1687 |
backend: str,
|
|
@@ -1691,10 +1836,11 @@ def translate_file_ui(
|
|
| 1691 |
honorific_kinship: bool,
|
| 1692 |
honorific_pronouns: bool,
|
| 1693 |
pronoun_harmonizer_v9: bool,
|
| 1694 |
-
auto_batch: bool,
|
| 1695 |
-
manual_batch: float,
|
| 1696 |
-
|
| 1697 |
-
|
|
|
|
| 1698 |
if file_obj is None:
|
| 1699 |
raise gr.Error("Chọn file .txt cần dịch.")
|
| 1700 |
|
|
@@ -1716,9 +1862,10 @@ def translate_file_ui(
|
|
| 1716 |
honorific_kinship,
|
| 1717 |
honorific_pronouns,
|
| 1718 |
pronoun_harmonizer_v9,
|
| 1719 |
-
auto_batch,
|
| 1720 |
-
manual_batch,
|
| 1721 |
-
|
|
|
|
| 1722 |
summary_prefix=f"Đã dịch từ `{path.name}` —",
|
| 1723 |
source_kind="file",
|
| 1724 |
session=_session_key(request),
|
|
@@ -1926,8 +2073,8 @@ def build_ui() -> gr.Blocks:
|
|
| 1926 |
elem_classes=["status-box"],
|
| 1927 |
)
|
| 1928 |
|
| 1929 |
-
with gr.Accordion("🧪 Tuỳ chọn chuẩn hóa xưng hô (nâng cao · thử nghiệm)", open=False,
|
| 1930 |
-
elem_id="honorific-accordion"):
|
| 1931 |
with gr.Row():
|
| 1932 |
honorific_kinship = gr.Checkbox(
|
| 1933 |
value=False, label="Thân tộc (tỷ / muội / ca ca…)", scale=1,
|
|
@@ -1946,11 +2093,58 @@ def build_ui() -> gr.Blocks:
|
|
| 1946 |
"khi route cấp chương là văn cổ trang · **Ổn định ngôi hiện đại** chỉ "
|
| 1947 |
"rewrite khi route hiện đại, ví dụ th��y/em, mẹ/con, anh/em. Mixed/unknown "
|
| 1948 |
"sẽ guard để tránh sửa quá tay.",
|
| 1949 |
-
elem_classes=["honorific-hint"],
|
| 1950 |
-
)
|
| 1951 |
-
|
| 1952 |
-
|
| 1953 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1954 |
gr.Markdown(
|
| 1955 |
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:
|
| 1956 |
|
|
@@ -2021,9 +2215,21 @@ mục `hachimimt`, chạy `setup.bat` (cài thư viện + tải model mặc đ
|
|
| 2021 |
inputs=[backend_select, model_select],
|
| 2022 |
outputs=[engine_hint, model_badge],
|
| 2023 |
)
|
| 2024 |
-
auto_batch.change(on_auto_batch_toggle, inputs=[auto_batch], outputs=[manual_batch])
|
| 2025 |
-
|
| 2026 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2027 |
lambda: gr.update(visible=True),
|
| 2028 |
outputs=[gpu_install_log],
|
| 2029 |
).then(
|
|
@@ -2134,11 +2340,11 @@ mục `hachimimt`, chạy `setup.bat` (cài thư viện + tải model mặc đ
|
|
| 2134 |
], feedback_context_state)
|
| 2135 |
translate_outputs = [progress_html, progress_active_state, *result_outputs]
|
| 2136 |
|
| 2137 |
-
translate_inputs = [
|
| 2138 |
-
model_select, backend_select, beam_size, chunk_mode, normalize_mode,
|
| 2139 |
-
honorific_kinship, honorific_pronouns, pronoun_harmonizer_v9,
|
| 2140 |
-
auto_batch, manual_batch,
|
| 2141 |
-
]
|
| 2142 |
|
| 2143 |
text_event = text_btn.click(
|
| 2144 |
prepare_text_progress_ui,
|
|
@@ -2176,10 +2382,10 @@ mục `hachimimt`, chạy `setup.bat` (cài thư viện + tải model mặc đ
|
|
| 2176 |
concurrency_id="translate",
|
| 2177 |
)
|
| 2178 |
|
| 2179 |
-
export_btn.click(
|
| 2180 |
-
|
| 2181 |
-
inputs=[full_text_state], outputs=[download_file],
|
| 2182 |
-
)
|
| 2183 |
|
| 2184 |
progress_timer = gr.Timer(0.5, active=True)
|
| 2185 |
progress_timer.tick(
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import atexit
|
| 6 |
+
import hashlib
|
| 7 |
+
import html
|
| 8 |
+
import os
|
| 9 |
+
import platform
|
| 10 |
import tempfile
|
| 11 |
import time
|
| 12 |
import unicodedata
|
|
|
|
| 44 |
|
| 45 |
_patch_windows_platform()
|
| 46 |
|
| 47 |
+
import gradio as gr
|
| 48 |
+
|
| 49 |
+
from glossary import (
|
| 50 |
+
GlossaryValidationError,
|
| 51 |
+
apply_glossary_rows,
|
| 52 |
+
compile_glossary,
|
| 53 |
+
glossary_table_rows,
|
| 54 |
+
read_glossary_file,
|
| 55 |
+
write_glossary_file,
|
| 56 |
+
)
|
| 57 |
+
import hardware
|
| 58 |
+
from hardware import detect_hardware_profile
|
| 59 |
+
from progress_tracker import finish_progress, reset_progress, set_progress, snapshot
|
| 60 |
from text_preprocess import (
|
| 61 |
NORMALIZE_AUTO,
|
| 62 |
NORMALIZE_NONE,
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
ROOT = Path(__file__).resolve().parent.parent
|
| 91 |
+
EXPORTS_DIR = ROOT / "exports"
|
| 92 |
+
PID_FILE = ROOT / ".hachimimt.pid"
|
| 93 |
+
APP_PORT = 7860
|
| 94 |
+
EXPORT_FILE_PREFIX = "hachimi_export_"
|
| 95 |
+
SPACE_EXPORT_MAX_AGE_SECONDS = 24 * 60 * 60
|
| 96 |
# HF Space tự set SPACE_ID. Khi ở Space: CPU-only (ẩn nút cài GPU/torch), process
|
| 97 |
# do HF quản (bỏ PID file), HF tự lo host/port (không ép 127.0.0.1). App chạy
|
| 98 |
# local KHÔNG đổi gì.
|
|
|
|
| 1244 |
return capped, notice
|
| 1245 |
|
| 1246 |
|
| 1247 |
+
def _exception_message(exc: Exception) -> str:
|
| 1248 |
+
return str(exc).strip() or exc.__class__.__name__
|
| 1249 |
+
|
| 1250 |
+
|
| 1251 |
+
def _request_session_token(request: gr.Request | None) -> str:
|
| 1252 |
+
"""Return a short, non-reversible scope token without exposing session_hash."""
|
| 1253 |
+
session_hash = str(getattr(request, "session_hash", "") or "")
|
| 1254 |
+
if not session_hash:
|
| 1255 |
+
return "local"
|
| 1256 |
+
return hashlib.sha256(session_hash.encode("utf-8")).hexdigest()[:12]
|
| 1257 |
+
|
| 1258 |
+
|
| 1259 |
+
def _unique_export_path(
|
| 1260 |
+
kind: str,
|
| 1261 |
+
suffix: str,
|
| 1262 |
+
request: gr.Request | None = None,
|
| 1263 |
+
) -> Path:
|
| 1264 |
+
safe_kind = "".join(c if c.isalnum() or c in "-_" else "_" for c in kind)
|
| 1265 |
+
safe_kind = safe_kind.strip("_")[:80] or "file"
|
| 1266 |
+
safe_suffix = suffix.lower().lstrip(".")
|
| 1267 |
+
if safe_suffix not in {"txt", "tsv", "json"}:
|
| 1268 |
+
raise ValueError(f"Unsupported export suffix: {suffix}")
|
| 1269 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 1270 |
+
session_token = _request_session_token(request)
|
| 1271 |
+
nonce = uuid.uuid4().hex
|
| 1272 |
+
return EXPORTS_DIR / (
|
| 1273 |
+
f"{EXPORT_FILE_PREFIX}{safe_kind}_{timestamp}_{session_token}_{nonce}.{safe_suffix}"
|
| 1274 |
+
)
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
def _cleanup_old_space_exports(
|
| 1278 |
+
*,
|
| 1279 |
+
now: float | None = None,
|
| 1280 |
+
max_age_seconds: int = SPACE_EXPORT_MAX_AGE_SECONDS,
|
| 1281 |
+
) -> None:
|
| 1282 |
+
"""Best-effort cleanup of this app's old ephemeral exports on HF Space only."""
|
| 1283 |
+
if not IS_HF_SPACE:
|
| 1284 |
+
return
|
| 1285 |
+
cutoff = (time.time() if now is None else now) - max_age_seconds
|
| 1286 |
+
try:
|
| 1287 |
+
candidates = list(EXPORTS_DIR.iterdir())
|
| 1288 |
+
except OSError:
|
| 1289 |
+
return
|
| 1290 |
+
for path in candidates:
|
| 1291 |
+
if (
|
| 1292 |
+
not path.is_file()
|
| 1293 |
+
or not path.name.startswith(EXPORT_FILE_PREFIX)
|
| 1294 |
+
or path.suffix.lower() not in {".txt", ".tsv", ".json"}
|
| 1295 |
+
):
|
| 1296 |
+
continue
|
| 1297 |
+
try:
|
| 1298 |
+
if path.stat().st_mtime < cutoff:
|
| 1299 |
+
path.unlink()
|
| 1300 |
+
except OSError:
|
| 1301 |
+
continue
|
| 1302 |
+
|
| 1303 |
+
|
| 1304 |
+
def export_translation(
|
| 1305 |
+
full_text: str,
|
| 1306 |
+
filename_stem: str,
|
| 1307 |
+
request: gr.Request | None = None,
|
| 1308 |
+
) -> str | None:
|
| 1309 |
+
if not full_text.strip():
|
| 1310 |
+
raise gr.Error("Chưa có bản dịch để xuất.")
|
| 1311 |
+
|
| 1312 |
+
safe_stem = "".join(c if c.isalnum() or c in "-_" else "_" for c in filename_stem) or "translation"
|
| 1313 |
+
_cleanup_old_space_exports()
|
| 1314 |
+
out_path = _unique_export_path(f"{safe_stem}_vi", "txt", request)
|
| 1315 |
+
out_path.write_text(full_text, encoding="utf-8")
|
| 1316 |
+
return str(out_path)
|
| 1317 |
+
|
| 1318 |
+
|
| 1319 |
+
def export_translation_ui(
|
| 1320 |
+
full_text: str,
|
| 1321 |
+
request: gr.Request | None = None,
|
| 1322 |
+
) -> str | None:
|
| 1323 |
+
return export_translation(full_text, "hachimimt", request)
|
| 1324 |
+
|
| 1325 |
+
|
| 1326 |
+
def import_glossary_ui(file_obj) -> tuple[list[list[object]], str]:
|
| 1327 |
+
if file_obj is None:
|
| 1328 |
+
raise gr.Error("Chọn file glossary .tsv hoặc .json.")
|
| 1329 |
+
try:
|
| 1330 |
+
rows = read_glossary_file(Path(file_obj))
|
| 1331 |
+
except (OSError, GlossaryValidationError) as exc:
|
| 1332 |
+
raise gr.Error(f"Không nạp được glossary: {_exception_message(exc)}") from exc
|
| 1333 |
+
return rows, f"Đã nạp **{len(rows)}** mục từ `{Path(file_obj).name}`."
|
| 1334 |
+
|
| 1335 |
+
|
| 1336 |
+
def export_glossary_ui(
|
| 1337 |
+
rows,
|
| 1338 |
+
file_format: str,
|
| 1339 |
+
request: gr.Request | None = None,
|
| 1340 |
+
) -> tuple[str, str]:
|
| 1341 |
+
try:
|
| 1342 |
+
table_rows = glossary_table_rows(rows)
|
| 1343 |
+
except GlossaryValidationError as exc:
|
| 1344 |
+
raise gr.Error(f"Glossary không hợp lệ: {_exception_message(exc)}") from exc
|
| 1345 |
+
if not table_rows:
|
| 1346 |
+
raise gr.Error("Glossary đang trống.")
|
| 1347 |
+
|
| 1348 |
+
file_format = (file_format or "tsv").strip().lower()
|
| 1349 |
+
if file_format not in {"tsv", "json"}:
|
| 1350 |
+
raise gr.Error("Định dạng glossary phải là TSV hoặc JSON.")
|
| 1351 |
+
_cleanup_old_space_exports()
|
| 1352 |
+
out_path = _unique_export_path("glossary", file_format, request)
|
| 1353 |
+
try:
|
| 1354 |
+
write_glossary_file(out_path, table_rows, file_format=file_format)
|
| 1355 |
+
except (OSError, GlossaryValidationError) as exc:
|
| 1356 |
+
raise gr.Error(f"Không xuất được glossary: {_exception_message(exc)}") from exc
|
| 1357 |
+
return str(out_path), f"Đã xuất **{len(table_rows)}** mục glossary."
|
| 1358 |
+
|
| 1359 |
+
|
| 1360 |
+
def _format_duration(seconds: float) -> str:
|
| 1361 |
+
"""Định dạng thời gian gọn: '12,3 giây' hoặc '2 phút 5 giây'."""
|
| 1362 |
if seconds < 60:
|
| 1363 |
return f"{seconds:.1f} giây".replace(".", ",")
|
| 1364 |
minutes = int(seconds // 60)
|
|
|
|
| 1598 |
}
|
| 1599 |
|
| 1600 |
|
| 1601 |
+
def _translate_run(
|
| 1602 |
+
source: str,
|
| 1603 |
+
model_key: str,
|
| 1604 |
backend: str,
|
| 1605 |
beam_size: float,
|
| 1606 |
chunk_mode: str,
|
|
|
|
| 1608 |
honorific_kinship: bool,
|
| 1609 |
honorific_pronouns: bool,
|
| 1610 |
pronoun_harmonizer_v9: bool,
|
| 1611 |
+
auto_batch: bool,
|
| 1612 |
+
manual_batch: float,
|
| 1613 |
+
*,
|
| 1614 |
+
glossary_rows=None,
|
| 1615 |
+
filename_stem: str,
|
| 1616 |
+
summary_prefix: str,
|
| 1617 |
+
source_kind: str,
|
| 1618 |
session: str | None = None,
|
| 1619 |
) -> Iterator[tuple]:
|
| 1620 |
try:
|
| 1621 |
source, space_cap_notice = cap_input_for_space(source)
|
| 1622 |
+
original_source = source
|
| 1623 |
+
source = normalize_chinese_text(source, normalize_mode)
|
| 1624 |
+
normalize_msg = normalization_message(original_source, source, normalize_mode)
|
| 1625 |
+
try:
|
| 1626 |
+
glossary_entries = compile_glossary(
|
| 1627 |
+
glossary_rows,
|
| 1628 |
+
normalize_source=lambda term: normalize_chinese_text(term, normalize_mode),
|
| 1629 |
+
)
|
| 1630 |
+
except GlossaryValidationError as exc:
|
| 1631 |
+
raise gr.Error(f"Glossary không hợp lệ: {_exception_message(exc)}") from exc
|
| 1632 |
+
honorific_kinship = bool(honorific_kinship)
|
| 1633 |
+
honorific_pronouns = bool(honorific_pronouns)
|
| 1634 |
honorific_on = honorific_kinship or honorific_pronouns
|
| 1635 |
pronoun_harmonizer_v9 = bool(pronoun_harmonizer_v9)
|
| 1636 |
|
|
|
|
| 1690 |
source_text=source,
|
| 1691 |
chunk_mode=chunk_mode,
|
| 1692 |
)
|
| 1693 |
+
genre_decision = postprocess_report.get("genre_decision")
|
| 1694 |
+
honorific_changed = int(postprocess_report.get("honorific_changed") or 0)
|
| 1695 |
+
honorific_pronouns_effective = bool(postprocess_report.get("honorific_pronouns_effective"))
|
| 1696 |
+
pronoun_report = postprocess_report.get("pronoun_report") or {}
|
| 1697 |
+
postprocess_warning = str(postprocess_report.get("warning") or "")
|
| 1698 |
+
glossary_report = None
|
| 1699 |
+
glossary_warning = ""
|
| 1700 |
+
if glossary_entries:
|
| 1701 |
+
before_glossary_rows = rows
|
| 1702 |
+
before_glossary_text = full_text
|
| 1703 |
+
try:
|
| 1704 |
+
rows, glossary_report = apply_glossary_rows(rows, glossary_entries)
|
| 1705 |
+
if glossary_report.changed_rows:
|
| 1706 |
+
full_text = rebuild_paragraph_layout(source, rows)
|
| 1707 |
+
except Exception as exc:
|
| 1708 |
+
rows = before_glossary_rows
|
| 1709 |
+
full_text = before_glossary_text
|
| 1710 |
+
glossary_warning = _exception_message(exc)
|
| 1711 |
+
|
| 1712 |
+
# KHÔNG auto-tạo file .txt mỗi lần dịch (tránh rác temp) — file chỉ sinh
|
| 1713 |
# khi user bấm nút "Xuất bản dịch .txt" (export_btn.click). Bản dịch đầy đủ
|
| 1714 |
# vẫn nằm trong ô + full_text_state để xuất khi cần.
|
| 1715 |
download_path = None
|
|
|
|
| 1746 |
f"route `{pronoun_report.get('route', 'n/a')}`."
|
| 1747 |
)
|
| 1748 |
route_note = f" Route hậu kỳ `{genre_decision.route}`." if genre_decision and (honorific_on or pronoun_harmonizer_v9) else ""
|
| 1749 |
+
warning_note = f" Hậu kỳ lỗi, đã giữ bản dịch thô: {postprocess_warning}." if postprocess_warning else ""
|
| 1750 |
+
glossary_note = ""
|
| 1751 |
+
if glossary_report is not None:
|
| 1752 |
+
glossary_note = (
|
| 1753 |
+
f" Glossary: {glossary_report.entries} mục, "
|
| 1754 |
+
f"{glossary_report.source_hits} hit nguồn; "
|
| 1755 |
+
f"{glossary_report.replacements} thay thế, "
|
| 1756 |
+
f"{glossary_report.satisfied} đã đúng, "
|
| 1757 |
+
f"{glossary_report.unresolved} chưa khớp alias."
|
| 1758 |
+
)
|
| 1759 |
+
elif glossary_warning:
|
| 1760 |
+
glossary_note = (
|
| 1761 |
+
f" Glossary lỗi, đã giữ bản trước glossary: {glossary_warning}."
|
| 1762 |
+
)
|
| 1763 |
+
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}"
|
| 1764 |
status = f"{status} · {time_note}"
|
| 1765 |
finish_progress(
|
| 1766 |
f"Hoàn tất — {chunk_count} chunk trong {duration} (100%)",
|
|
|
|
| 1788 |
raise
|
| 1789 |
|
| 1790 |
|
| 1791 |
+
def translate_text_ui(
|
| 1792 |
source: str,
|
| 1793 |
model_key: str,
|
| 1794 |
backend: str,
|
|
|
|
| 1798 |
honorific_kinship: bool,
|
| 1799 |
honorific_pronouns: bool,
|
| 1800 |
pronoun_harmonizer_v9: bool,
|
| 1801 |
+
auto_batch: bool,
|
| 1802 |
+
manual_batch: float,
|
| 1803 |
+
glossary_rows=None,
|
| 1804 |
+
request: gr.Request | None = None,
|
| 1805 |
+
) -> Iterator[tuple]:
|
| 1806 |
if not source.strip():
|
| 1807 |
raise gr.Error("Nhập văn bản tiếng Trung cần dịch.")
|
| 1808 |
|
|
|
|
| 1816 |
honorific_kinship,
|
| 1817 |
honorific_pronouns,
|
| 1818 |
pronoun_harmonizer_v9,
|
| 1819 |
+
auto_batch,
|
| 1820 |
+
manual_batch,
|
| 1821 |
+
glossary_rows=glossary_rows,
|
| 1822 |
+
filename_stem="hachimimt",
|
| 1823 |
summary_prefix="Đã dịch",
|
| 1824 |
source_kind="text",
|
| 1825 |
session=_session_key(request),
|
| 1826 |
)
|
| 1827 |
|
| 1828 |
|
| 1829 |
+
def translate_file_ui(
|
| 1830 |
file_obj,
|
| 1831 |
model_key: str,
|
| 1832 |
backend: str,
|
|
|
|
| 1836 |
honorific_kinship: bool,
|
| 1837 |
honorific_pronouns: bool,
|
| 1838 |
pronoun_harmonizer_v9: bool,
|
| 1839 |
+
auto_batch: bool,
|
| 1840 |
+
manual_batch: float,
|
| 1841 |
+
glossary_rows=None,
|
| 1842 |
+
request: gr.Request | None = None,
|
| 1843 |
+
) -> Iterator[tuple]:
|
| 1844 |
if file_obj is None:
|
| 1845 |
raise gr.Error("Chọn file .txt cần dịch.")
|
| 1846 |
|
|
|
|
| 1862 |
honorific_kinship,
|
| 1863 |
honorific_pronouns,
|
| 1864 |
pronoun_harmonizer_v9,
|
| 1865 |
+
auto_batch,
|
| 1866 |
+
manual_batch,
|
| 1867 |
+
glossary_rows=glossary_rows,
|
| 1868 |
+
filename_stem=path.stem,
|
| 1869 |
summary_prefix=f"Đã dịch từ `{path.name}` —",
|
| 1870 |
source_kind="file",
|
| 1871 |
session=_session_key(request),
|
|
|
|
| 2073 |
elem_classes=["status-box"],
|
| 2074 |
)
|
| 2075 |
|
| 2076 |
+
with gr.Accordion("🧪 Tuỳ chọn chuẩn hóa xưng hô (nâng cao · thử nghiệm)", open=False,
|
| 2077 |
+
elem_id="honorific-accordion"):
|
| 2078 |
with gr.Row():
|
| 2079 |
honorific_kinship = gr.Checkbox(
|
| 2080 |
value=False, label="Thân tộc (tỷ / muội / ca ca…)", scale=1,
|
|
|
|
| 2093 |
"khi route cấp chương là văn cổ trang · **Ổn định ngôi hiện đại** chỉ "
|
| 2094 |
"rewrite khi route hiện đại, ví dụ th��y/em, mẹ/con, anh/em. Mixed/unknown "
|
| 2095 |
"sẽ guard để tránh sửa quá tay.",
|
| 2096 |
+
elem_classes=["honorific-hint"],
|
| 2097 |
+
)
|
| 2098 |
+
|
| 2099 |
+
with gr.Accordion("📚 Tên riêng & thuật ngữ (glossary)", open=False,
|
| 2100 |
+
elem_id="glossary-accordion"):
|
| 2101 |
+
gr.Markdown(
|
| 2102 |
+
"Glossary chỉ chuẩn hóa **alias tiếng Việt** khi dòng nguồn chứa đúng "
|
| 2103 |
+
"`source_zh`; không tự chèn tên bị model bỏ sót. "
|
| 2104 |
+
"`Loại` chỉ để quản lý và **không bắt buộc**. Nhiều alias ngăn bằng `|`.",
|
| 2105 |
+
elem_classes=["honorific-hint"],
|
| 2106 |
+
)
|
| 2107 |
+
glossary_table = gr.Dataframe(
|
| 2108 |
+
value=[["", "", "", "", True]],
|
| 2109 |
+
headers=[
|
| 2110 |
+
"Nguồn Trung *",
|
| 2111 |
+
"Đích Việt *",
|
| 2112 |
+
"Loại (tùy chọn)",
|
| 2113 |
+
"Alias Việt (ngăn bằng |)",
|
| 2114 |
+
"Bật",
|
| 2115 |
+
],
|
| 2116 |
+
datatype=["str", "str", "str", "str", "bool"],
|
| 2117 |
+
type="array",
|
| 2118 |
+
row_count=3,
|
| 2119 |
+
column_count=5,
|
| 2120 |
+
column_widths=["20%", "22%", "16%", "32%", "10%"],
|
| 2121 |
+
max_height=360,
|
| 2122 |
+
interactive=True,
|
| 2123 |
+
show_row_numbers=True,
|
| 2124 |
+
label="Glossary của phiên hiện tại",
|
| 2125 |
+
elem_id="glossary-table",
|
| 2126 |
+
)
|
| 2127 |
+
with gr.Row():
|
| 2128 |
+
glossary_file = gr.File(
|
| 2129 |
+
label="Nhập glossary (.tsv / .json)",
|
| 2130 |
+
file_types=[".tsv", ".json"],
|
| 2131 |
+
type="filepath",
|
| 2132 |
+
scale=3,
|
| 2133 |
+
)
|
| 2134 |
+
glossary_format = gr.Radio(
|
| 2135 |
+
[("TSV", "tsv"), ("JSON", "json")],
|
| 2136 |
+
value="tsv",
|
| 2137 |
+
label="Định dạng xuất",
|
| 2138 |
+
scale=1,
|
| 2139 |
+
)
|
| 2140 |
+
with gr.Row():
|
| 2141 |
+
glossary_import_btn = gr.Button("Nạp vào bảng", variant="secondary")
|
| 2142 |
+
glossary_export_btn = gr.Button("Xuất glossary", variant="secondary")
|
| 2143 |
+
glossary_download = gr.File(label="Tải glossary")
|
| 2144 |
+
glossary_status = gr.Markdown()
|
| 2145 |
+
|
| 2146 |
+
if IS_HF_SPACE:
|
| 2147 |
+
with gr.Accordion("🚀 Dùng nhanh/mạnh hơn — Google Colab/Kaggle (GPU) hoặc cài máy (offline)", open=False):
|
| 2148 |
gr.Markdown(
|
| 2149 |
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:
|
| 2150 |
|
|
|
|
| 2215 |
inputs=[backend_select, model_select],
|
| 2216 |
outputs=[engine_hint, model_badge],
|
| 2217 |
)
|
| 2218 |
+
auto_batch.change(on_auto_batch_toggle, inputs=[auto_batch], outputs=[manual_batch])
|
| 2219 |
+
glossary_import_btn.click(
|
| 2220 |
+
import_glossary_ui,
|
| 2221 |
+
inputs=[glossary_file],
|
| 2222 |
+
outputs=[glossary_table, glossary_status],
|
| 2223 |
+
queue=False,
|
| 2224 |
+
)
|
| 2225 |
+
glossary_export_btn.click(
|
| 2226 |
+
export_glossary_ui,
|
| 2227 |
+
inputs=[glossary_table, glossary_format],
|
| 2228 |
+
outputs=[glossary_download, glossary_status],
|
| 2229 |
+
queue=False,
|
| 2230 |
+
)
|
| 2231 |
+
|
| 2232 |
+
gpu_install_btn.click(
|
| 2233 |
lambda: gr.update(visible=True),
|
| 2234 |
outputs=[gpu_install_log],
|
| 2235 |
).then(
|
|
|
|
| 2340 |
], feedback_context_state)
|
| 2341 |
translate_outputs = [progress_html, progress_active_state, *result_outputs]
|
| 2342 |
|
| 2343 |
+
translate_inputs = [
|
| 2344 |
+
model_select, backend_select, beam_size, chunk_mode, normalize_mode,
|
| 2345 |
+
honorific_kinship, honorific_pronouns, pronoun_harmonizer_v9,
|
| 2346 |
+
auto_batch, manual_batch, glossary_table,
|
| 2347 |
+
]
|
| 2348 |
|
| 2349 |
text_event = text_btn.click(
|
| 2350 |
prepare_text_progress_ui,
|
|
|
|
| 2382 |
concurrency_id="translate",
|
| 2383 |
)
|
| 2384 |
|
| 2385 |
+
export_btn.click(
|
| 2386 |
+
export_translation_ui,
|
| 2387 |
+
inputs=[full_text_state], outputs=[download_file],
|
| 2388 |
+
)
|
| 2389 |
|
| 2390 |
progress_timer = gr.Timer(0.5, active=True)
|
| 2391 |
progress_timer.tick(
|
src/glossary.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""User glossary parsing and safe post-translation canonicalization.
|
| 2 |
+
|
| 3 |
+
The MVP intentionally does not inject placeholders into model input. It only
|
| 4 |
+
canonicalizes an explicitly listed Vietnamese alias when the corresponding
|
| 5 |
+
Chinese term is present in the same source row.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import csv
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
import unicodedata
|
| 14 |
+
from collections.abc import Callable, Iterable, Sequence
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
GLOSSARY_HEADERS = ("source_zh", "target_vi", "type", "aliases_vi", "enabled")
|
| 20 |
+
MAX_GLOSSARY_BYTES = 2_000_000
|
| 21 |
+
MAX_GLOSSARY_ROWS = 5_000
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class GlossaryValidationError(ValueError):
|
| 25 |
+
"""Raised when active glossary rows are ambiguous or incomplete."""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True, slots=True)
|
| 29 |
+
class GlossaryEntry:
|
| 30 |
+
source_zh: str
|
| 31 |
+
target_vi: str
|
| 32 |
+
entry_type: str = ""
|
| 33 |
+
aliases_vi: tuple[str, ...] = ()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass(frozen=True, slots=True)
|
| 37 |
+
class GlossaryReport:
|
| 38 |
+
entries: int
|
| 39 |
+
source_hits: int
|
| 40 |
+
replacements: int
|
| 41 |
+
satisfied: int
|
| 42 |
+
unresolved: int
|
| 43 |
+
changed_rows: int
|
| 44 |
+
unresolved_terms: tuple[str, ...] = ()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _clean_text(value: Any) -> str:
|
| 48 |
+
if value is None:
|
| 49 |
+
return ""
|
| 50 |
+
try:
|
| 51 |
+
if value != value: # NaN from a pandas-backed Dataframe.
|
| 52 |
+
return ""
|
| 53 |
+
except (TypeError, ValueError):
|
| 54 |
+
pass
|
| 55 |
+
return unicodedata.normalize("NFC", str(value).strip())
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _aliases_cell(value: Any) -> str:
|
| 59 |
+
if isinstance(value, (list, tuple, set)):
|
| 60 |
+
return "|".join(_clean_text(item) for item in value if _clean_text(item))
|
| 61 |
+
return _clean_text(value)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _parse_enabled(value: Any) -> bool:
|
| 65 |
+
if value is None or _clean_text(value) == "":
|
| 66 |
+
return True
|
| 67 |
+
if isinstance(value, bool):
|
| 68 |
+
return value
|
| 69 |
+
if isinstance(value, (int, float)):
|
| 70 |
+
return bool(value)
|
| 71 |
+
normalized = _clean_text(value).lower()
|
| 72 |
+
if normalized in {"1", "true", "yes", "y", "on", "x", "✓", "bật"}:
|
| 73 |
+
return True
|
| 74 |
+
if normalized in {"0", "false", "no", "n", "off", "✗", "tắt"}:
|
| 75 |
+
return False
|
| 76 |
+
raise GlossaryValidationError(
|
| 77 |
+
f"Giá trị enabled không hợp lệ: {value!r}; dùng true/false hoặc 1/0."
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def glossary_table_rows(value: Any) -> list[list[Any]]:
|
| 82 |
+
"""Coerce Gradio/pandas/JSON values to the five-column UI table shape."""
|
| 83 |
+
if value is None:
|
| 84 |
+
return []
|
| 85 |
+
if hasattr(value, "values") and hasattr(value.values, "tolist"):
|
| 86 |
+
value = value.values.tolist()
|
| 87 |
+
elif hasattr(value, "tolist") and not isinstance(value, (str, bytes, dict)):
|
| 88 |
+
value = value.tolist()
|
| 89 |
+
if isinstance(value, dict) and "data" in value:
|
| 90 |
+
value = value["data"]
|
| 91 |
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
| 92 |
+
raise GlossaryValidationError("Glossary phải là một bảng hoặc danh sách các dòng.")
|
| 93 |
+
|
| 94 |
+
rows: list[list[Any]] = []
|
| 95 |
+
for row_index, raw_row in enumerate(value, start=1):
|
| 96 |
+
if isinstance(raw_row, dict):
|
| 97 |
+
row = [
|
| 98 |
+
raw_row.get("source_zh", raw_row.get("source", "")),
|
| 99 |
+
raw_row.get("target_vi", raw_row.get("target", "")),
|
| 100 |
+
raw_row.get("type", raw_row.get("entry_type", "")),
|
| 101 |
+
raw_row.get("aliases_vi", raw_row.get("aliases", "")),
|
| 102 |
+
raw_row.get("enabled", True),
|
| 103 |
+
]
|
| 104 |
+
elif isinstance(raw_row, Sequence) and not isinstance(raw_row, (str, bytes)):
|
| 105 |
+
row = list(raw_row[: len(GLOSSARY_HEADERS)])
|
| 106 |
+
row.extend([""] * (len(GLOSSARY_HEADERS) - len(row)))
|
| 107 |
+
if len(raw_row) < len(GLOSSARY_HEADERS):
|
| 108 |
+
row[-1] = True
|
| 109 |
+
else:
|
| 110 |
+
raise GlossaryValidationError(f"Dòng glossary {row_index} không phải một hàng dữ liệu.")
|
| 111 |
+
|
| 112 |
+
source = _clean_text(row[0])
|
| 113 |
+
target = _clean_text(row[1])
|
| 114 |
+
entry_type = _clean_text(row[2])
|
| 115 |
+
aliases = _aliases_cell(row[3])
|
| 116 |
+
if not any((source, target, entry_type, aliases)):
|
| 117 |
+
continue
|
| 118 |
+
rows.append([source, target, entry_type, aliases, _parse_enabled(row[4])])
|
| 119 |
+
if len(rows) > MAX_GLOSSARY_ROWS:
|
| 120 |
+
raise GlossaryValidationError(
|
| 121 |
+
f"Glossary vượt giới hạn {MAX_GLOSSARY_ROWS:,} dòng."
|
| 122 |
+
)
|
| 123 |
+
return rows
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _parse_aliases(value: str, target_vi: str) -> tuple[str, ...]:
|
| 127 |
+
aliases: list[str] = []
|
| 128 |
+
seen = {target_vi}
|
| 129 |
+
for raw_alias in value.split("|"):
|
| 130 |
+
alias = _clean_text(raw_alias)
|
| 131 |
+
if alias and alias not in seen:
|
| 132 |
+
aliases.append(alias)
|
| 133 |
+
seen.add(alias)
|
| 134 |
+
aliases.sort(key=lambda item: (-len(item), item))
|
| 135 |
+
return tuple(aliases)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def compile_glossary(
|
| 139 |
+
value: Any,
|
| 140 |
+
*,
|
| 141 |
+
normalize_source: Callable[[str], str] | None = None,
|
| 142 |
+
) -> list[GlossaryEntry]:
|
| 143 |
+
"""Validate active rows, normalize keys, merge identical mappings."""
|
| 144 |
+
normalize_source = normalize_source or (lambda text: text)
|
| 145 |
+
merged: dict[str, GlossaryEntry] = {}
|
| 146 |
+
first_rows: dict[str, int] = {}
|
| 147 |
+
|
| 148 |
+
for row_index, row in enumerate(glossary_table_rows(value), start=1):
|
| 149 |
+
source_zh, target_vi, entry_type, aliases_cell, enabled = row
|
| 150 |
+
if not enabled:
|
| 151 |
+
continue
|
| 152 |
+
if not source_zh or not target_vi:
|
| 153 |
+
raise GlossaryValidationError(
|
| 154 |
+
f"Dòng glossary {row_index}: source_zh và target_vi là bắt buộc "
|
| 155 |
+
"khi mục đang bật."
|
| 156 |
+
)
|
| 157 |
+
normalized_source = _clean_text(normalize_source(source_zh))
|
| 158 |
+
if not normalized_source:
|
| 159 |
+
raise GlossaryValidationError(
|
| 160 |
+
f"Dòng glossary {row_index}: source_zh rỗng sau chuẩn hóa."
|
| 161 |
+
)
|
| 162 |
+
aliases = _parse_aliases(aliases_cell, target_vi)
|
| 163 |
+
existing = merged.get(normalized_source)
|
| 164 |
+
if existing is None:
|
| 165 |
+
merged[normalized_source] = GlossaryEntry(
|
| 166 |
+
source_zh=normalized_source,
|
| 167 |
+
target_vi=target_vi,
|
| 168 |
+
entry_type=entry_type,
|
| 169 |
+
aliases_vi=aliases,
|
| 170 |
+
)
|
| 171 |
+
first_rows[normalized_source] = row_index
|
| 172 |
+
continue
|
| 173 |
+
if existing.target_vi != target_vi:
|
| 174 |
+
raise GlossaryValidationError(
|
| 175 |
+
f"Xung đột source_zh {normalized_source!r}: dòng "
|
| 176 |
+
f"{first_rows[normalized_source]} → {existing.target_vi!r}, "
|
| 177 |
+
f"dòng {row_index} → {target_vi!r}."
|
| 178 |
+
)
|
| 179 |
+
combined_aliases = tuple(
|
| 180 |
+
sorted(
|
| 181 |
+
set(existing.aliases_vi).union(aliases),
|
| 182 |
+
key=lambda item: (-len(item), item),
|
| 183 |
+
)
|
| 184 |
+
)
|
| 185 |
+
merged[normalized_source] = GlossaryEntry(
|
| 186 |
+
source_zh=normalized_source,
|
| 187 |
+
target_vi=target_vi,
|
| 188 |
+
entry_type=existing.entry_type or entry_type,
|
| 189 |
+
aliases_vi=combined_aliases,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
return sorted(
|
| 193 |
+
merged.values(),
|
| 194 |
+
key=lambda entry: (-len(entry.source_zh), entry.source_zh, entry.target_vi),
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _source_matches(
|
| 199 |
+
source_text: str,
|
| 200 |
+
entries: Sequence[GlossaryEntry],
|
| 201 |
+
) -> list[GlossaryEntry]:
|
| 202 |
+
candidates: list[tuple[int, int, int, GlossaryEntry]] = []
|
| 203 |
+
for entry_index, entry in enumerate(entries):
|
| 204 |
+
start = source_text.find(entry.source_zh)
|
| 205 |
+
while start >= 0:
|
| 206 |
+
end = start + len(entry.source_zh)
|
| 207 |
+
candidates.append((start, -len(entry.source_zh), entry_index, entry))
|
| 208 |
+
start = source_text.find(entry.source_zh, start + 1)
|
| 209 |
+
candidates.sort(key=lambda item: item[:3])
|
| 210 |
+
|
| 211 |
+
selected: list[GlossaryEntry] = []
|
| 212 |
+
occupied_until = -1
|
| 213 |
+
for start, negative_length, _entry_index, entry in candidates:
|
| 214 |
+
end = start - negative_length
|
| 215 |
+
if start < occupied_until:
|
| 216 |
+
continue
|
| 217 |
+
selected.append(entry)
|
| 218 |
+
occupied_until = end
|
| 219 |
+
return selected
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _literal_pattern(value: str) -> re.Pattern[str]:
|
| 223 |
+
left = r"(?<!\w)" if value[0].isalnum() or value[0] == "_" else ""
|
| 224 |
+
right = r"(?!\w)" if value[-1].isalnum() or value[-1] == "_" else ""
|
| 225 |
+
return re.compile(f"{left}{re.escape(value)}{right}")
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _replace_entry_aliases(text: str, entry: GlossaryEntry) -> tuple[str, int, int]:
|
| 229 |
+
marker_index = 0
|
| 230 |
+
marker = "\ue000HACHIMI_GLOSSARY\ue001"
|
| 231 |
+
while marker in text or marker in entry.target_vi or marker in entry.aliases_vi:
|
| 232 |
+
marker_index += 1
|
| 233 |
+
marker = f"\ue000HACHIMI_GLOSSARY_{marker_index}\ue001"
|
| 234 |
+
|
| 235 |
+
protected, canonical_count = _literal_pattern(entry.target_vi).subn(marker, text)
|
| 236 |
+
replacements = 0
|
| 237 |
+
for alias in entry.aliases_vi:
|
| 238 |
+
protected, count = _literal_pattern(alias).subn(marker, protected)
|
| 239 |
+
replacements += count
|
| 240 |
+
return protected.replace(marker, entry.target_vi), replacements, canonical_count
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def apply_glossary_rows(
|
| 244 |
+
rows: Iterable[tuple[int, str, str]],
|
| 245 |
+
entries: Sequence[GlossaryEntry],
|
| 246 |
+
) -> tuple[list[tuple[int, str, str]], GlossaryReport]:
|
| 247 |
+
"""Apply aliases only in rows whose source contains the mapped Chinese term."""
|
| 248 |
+
output_rows: list[tuple[int, str, str]] = []
|
| 249 |
+
source_hits = 0
|
| 250 |
+
replacements = 0
|
| 251 |
+
satisfied = 0
|
| 252 |
+
unresolved = 0
|
| 253 |
+
changed_rows = 0
|
| 254 |
+
unresolved_terms: set[str] = set()
|
| 255 |
+
|
| 256 |
+
for index, source_zh, translated_vi in rows:
|
| 257 |
+
matches = _source_matches(source_zh, entries)
|
| 258 |
+
source_hits += len(matches)
|
| 259 |
+
unique_matches = list(dict.fromkeys(matches))
|
| 260 |
+
fixed_vi = translated_vi
|
| 261 |
+
row_changed = False
|
| 262 |
+
for entry in unique_matches:
|
| 263 |
+
fixed_vi, replaced_count, canonical_count = _replace_entry_aliases(fixed_vi, entry)
|
| 264 |
+
if replaced_count:
|
| 265 |
+
replacements += replaced_count
|
| 266 |
+
row_changed = True
|
| 267 |
+
elif canonical_count:
|
| 268 |
+
satisfied += 1
|
| 269 |
+
else:
|
| 270 |
+
unresolved += 1
|
| 271 |
+
unresolved_terms.add(entry.source_zh)
|
| 272 |
+
if row_changed:
|
| 273 |
+
changed_rows += 1
|
| 274 |
+
output_rows.append((index, source_zh, fixed_vi))
|
| 275 |
+
|
| 276 |
+
report = GlossaryReport(
|
| 277 |
+
entries=len(entries),
|
| 278 |
+
source_hits=source_hits,
|
| 279 |
+
replacements=replacements,
|
| 280 |
+
satisfied=satisfied,
|
| 281 |
+
unresolved=unresolved,
|
| 282 |
+
changed_rows=changed_rows,
|
| 283 |
+
unresolved_terms=tuple(sorted(unresolved_terms)),
|
| 284 |
+
)
|
| 285 |
+
return output_rows, report
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def read_glossary_file(path: Path) -> list[list[Any]]:
|
| 289 |
+
path = Path(path)
|
| 290 |
+
if path.suffix.lower() not in {".tsv", ".json"}:
|
| 291 |
+
raise GlossaryValidationError("Chỉ hỗ trợ glossary .tsv hoặc .json.")
|
| 292 |
+
if path.stat().st_size > MAX_GLOSSARY_BYTES:
|
| 293 |
+
raise GlossaryValidationError(
|
| 294 |
+
f"File glossary vượt giới hạn {MAX_GLOSSARY_BYTES // 1_000_000} MB."
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
if path.suffix.lower() == ".json":
|
| 298 |
+
try:
|
| 299 |
+
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
| 300 |
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
| 301 |
+
raise GlossaryValidationError(f"Không đọc được JSON glossary: {exc}") from exc
|
| 302 |
+
if isinstance(payload, dict) and "entries" in payload:
|
| 303 |
+
payload = payload["entries"]
|
| 304 |
+
return glossary_table_rows(payload)
|
| 305 |
+
|
| 306 |
+
try:
|
| 307 |
+
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
| 308 |
+
raw_rows = list(csv.reader(handle, delimiter="\t"))
|
| 309 |
+
except UnicodeDecodeError as exc:
|
| 310 |
+
raise GlossaryValidationError("TSV glossary phải dùng UTF-8.") from exc
|
| 311 |
+
if not raw_rows:
|
| 312 |
+
return []
|
| 313 |
+
|
| 314 |
+
normalized_header = [_clean_text(item).lower() for item in raw_rows[0]]
|
| 315 |
+
if {"source_zh", "target_vi"}.issubset(normalized_header):
|
| 316 |
+
positions = {name: normalized_header.index(name) for name in GLOSSARY_HEADERS if name in normalized_header}
|
| 317 |
+
data_rows = []
|
| 318 |
+
for raw_row in raw_rows[1:]:
|
| 319 |
+
data_rows.append(
|
| 320 |
+
[
|
| 321 |
+
raw_row[positions[name]] if name in positions and positions[name] < len(raw_row)
|
| 322 |
+
else (True if name == "enabled" else "")
|
| 323 |
+
for name in GLOSSARY_HEADERS
|
| 324 |
+
]
|
| 325 |
+
)
|
| 326 |
+
else:
|
| 327 |
+
data_rows = raw_rows
|
| 328 |
+
return glossary_table_rows(data_rows)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def write_glossary_file(path: Path, value: Any, *, file_format: str = "tsv") -> Path:
|
| 332 |
+
rows = glossary_table_rows(value)
|
| 333 |
+
path = Path(path)
|
| 334 |
+
file_format = _clean_text(file_format).lower()
|
| 335 |
+
if file_format == "json":
|
| 336 |
+
payload = [
|
| 337 |
+
dict(zip(GLOSSARY_HEADERS, row, strict=True))
|
| 338 |
+
for row in rows
|
| 339 |
+
]
|
| 340 |
+
path.write_text(
|
| 341 |
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
| 342 |
+
encoding="utf-8",
|
| 343 |
+
)
|
| 344 |
+
return path
|
| 345 |
+
if file_format != "tsv":
|
| 346 |
+
raise GlossaryValidationError("Định dạng xuất glossary phải là tsv hoặc json.")
|
| 347 |
+
with path.open("w", encoding="utf-8", newline="") as handle:
|
| 348 |
+
writer = csv.writer(handle, delimiter="\t", lineterminator="\n")
|
| 349 |
+
writer.writerow(GLOSSARY_HEADERS)
|
| 350 |
+
writer.writerows(rows)
|
| 351 |
+
return path
|