ngocdang83 commited on
Commit
ba02d85
·
verified ·
1 Parent(s): 5e2dcde

Deploy session-scoped glossary (GitHub 26806a5)

Browse files
Files changed (5) hide show
  1. README.md +2 -8
  2. README_hachimimt_colab.md +6 -3
  3. hachimimt-local.zip +2 -2
  4. src/app.py +293 -87
  5. 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
- **từ điển pin tay của QuickTranslator vẫn nhỉnh hơn** bạn cố định một tên là cố
177
- định mãi. (HachimiMT không cho sửa từ điển.)
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
- - ** hình cố định**, không tùy biến thuật ngữ / không thêm từ điển riêng.
 
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 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**: chuẩn hóa alias sau dịch thay
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:e8f2f549b3c44a83304fbe7b333d642558737c195f984d0cd9d14b734d3c8137
3
- size 128715
 
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 html
7
- import os
8
- import platform
 
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 hardware
49
- from hardware import detect_hardware_profile
50
- from progress_tracker import finish_progress, reset_progress, set_progress, snapshot
 
 
 
 
 
 
 
 
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 export_translation(full_text: str, filename_stem: str) -> str | None:
1241
- if not full_text.strip():
1242
- raise gr.Error("Chưa bản dịch để xuất.")
1243
-
1244
- safe_stem = "".join(c if c.isalnum() or c in "-_" else "_" for c in filename_stem) or "translation"
1245
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1246
- out_path = EXPORTS_DIR / f"{safe_stem}_vi_{timestamp}.txt"
1247
- out_path.write_text(full_text, encoding="utf-8")
1248
- return str(out_path)
1249
-
1250
-
1251
- def _format_duration(seconds: float) -> str:
1252
- """Định dạng thời gian gọn: '12,3 giây' hoặc '2 phút 5 giây'."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- filename_stem: str,
1506
- summary_prefix: str,
1507
- source_kind: str,
 
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
- honorific_kinship = bool(honorific_kinship)
1516
- honorific_pronouns = bool(honorific_pronouns)
 
 
 
 
 
 
 
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
- # KHÔNG auto-tạo file .txt mỗi lần dịch (tránh rác temp) — file chỉ sinh
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- summary = f"{summary_prefix} **{chunk_count}** chunk · {time_note}. {normalize_msg}{fallback_note}{route_note}{honorific_note}{pronoun_note}{warning_note}{space_cap_notice}"
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- request: gr.Request | None = None,
1661
- ) -> Iterator[tuple]:
 
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
- filename_stem="hachimimt",
 
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
- request: gr.Request | None = None,
1697
- ) -> Iterator[tuple]:
 
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
- filename_stem=path.stem,
 
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
- if IS_HF_SPACE:
1953
- with gr.Accordion("🚀 Dùng nhanh/mạnh hơn — Google Colab/Kaggle (GPU) hoặc cài máy (offline)", open=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- gpu_install_btn.click(
 
 
 
 
 
 
 
 
 
 
 
 
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
- lambda text: export_translation(text, "hachimimt"),
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