ngocdang83 commited on
Commit
5e80a7e
·
verified ·
1 Parent(s): 53304ea

feat: add glossary placeholder restore

Browse files
Files changed (3) hide show
  1. hachimimt-local.zip +2 -2
  2. src/app.py +149 -35
  3. src/glossary.py +301 -4
hachimimt-local.zip CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:a3511d1f3a5a8e0e44de5cbd24daedc7e9226c3bd9bf0efb0d7688972b48a0d9
3
- size 136689
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:db6a42ac152095b5d31d2a72e7f7ff849df0b7453f7cede80ccba4af9a869193
3
+ size 141638
src/app.py CHANGED
@@ -47,11 +47,15 @@ _patch_windows_platform()
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
@@ -1516,7 +1520,7 @@ def _layout_lines(text: str) -> list[str]:
1516
  return (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
1517
 
1518
 
1519
- def rebuild_paragraph_layout(
1520
  source_text: str, rows: list[tuple[int, str, str]]
1521
  ) -> str:
1522
  """Ghép bản dịch của các dòng (rows) trở lại ĐÚNG bố cục dòng của nguồn.
@@ -1535,10 +1539,64 @@ def rebuild_paragraph_layout(
1535
  sentinel = object()
1536
  if next(translated_iter, sentinel) is not sentinel:
1537
  raise RuntimeError("Số row dịch lớn hơn số dòng nguồn không rỗng")
1538
- return "\n".join(output)
1539
-
1540
-
1541
- def apply_postprocess_rows(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1542
  rows: list[tuple[int, str, str]],
1543
  *,
1544
  honorific_kinship: bool,
@@ -1680,11 +1738,22 @@ def _translate_run(
1680
  raise gr.Error(f"Glossary không hợp lệ: {_exception_message(exc)}") from exc
1681
  honorific_kinship = bool(honorific_kinship)
1682
  honorific_pronouns = bool(honorific_pronouns)
1683
- honorific_on = honorific_kinship or honorific_pronouns
1684
- pronoun_harmonizer_v9 = bool(pronoun_harmonizer_v9)
1685
-
1686
- if is_model_downloaded(model_key, backend):
1687
- load_msg = "Đang nạp model..."
 
 
 
 
 
 
 
 
 
 
 
1688
  else:
1689
  label = MODELS[model_key].label if model_key in MODELS else model_key
1690
  load_msg = f"Đang tải model {label} từ Hugging Face (lần đầu, vui lòng đợi)..."
@@ -1694,20 +1763,29 @@ def _translate_run(
1694
  resolve_batch_size(auto_batch, manual_batch)
1695
  status = ensure_model(model_key, backend, beam_size)
1696
 
1697
- set_progress(2, f"{normalize_msg} Đang chia chunk...", session=session)
1698
- yield _progress_stream_update(session=session)
 
 
 
 
 
 
 
 
 
1699
 
1700
  rows: list[tuple[int, str, str]] = []
1701
  full_text = ""
1702
  last_progress_update = 0.0
1703
  translate_start = time.perf_counter()
1704
- run_id = uuid.uuid4().hex[:8]
1705
-
1706
- for done, total, message, result_rows, result_text in translator.translate_text_iter(
1707
- source,
1708
- chunk_mode=chunk_mode,
1709
- beam_size=int(beam_size),
1710
- ):
1711
  if result_rows is not None and result_text is not None:
1712
  rows = result_rows
1713
  full_text = result_text
@@ -1725,12 +1803,33 @@ def _translate_run(
1725
  pct = round(done / max(total, 1) * 100, 1)
1726
  detail = f"{message} ({pct}%)"
1727
  set_progress(pct, detail, session=session)
1728
- last_progress_update = now
1729
- yield _progress_stream_update(session=session)
1730
-
1731
- translate_seconds = time.perf_counter() - translate_start
1732
-
1733
- rows, full_text, postprocess_report = apply_postprocess_rows(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1734
  rows,
1735
  honorific_kinship=honorific_kinship,
1736
  honorific_pronouns=honorific_pronouns,
@@ -1752,19 +1851,20 @@ def _translate_run(
1752
  try:
1753
  rows, glossary_report = apply_glossary_rows(rows, glossary_entries)
1754
  if glossary_report.changed_rows:
1755
- full_text = rebuild_paragraph_layout(source, rows)
1756
  except Exception as exc:
1757
  rows = before_glossary_rows
1758
  full_text = before_glossary_text
1759
  glossary_warning = _exception_message(exc)
1760
 
 
 
1761
  # KHÔNG auto-tạo file .txt mỗi lần dịch (tránh rác temp) — file chỉ sinh
1762
  # khi user bấm nút "Xuất bản dịch .txt" (export_btn.click). Bản dịch đầy đủ
1763
  # vẫn nằm trong ô + full_text_state để xuất khi cần.
1764
- download_path = None
1765
- duration = _format_duration(translate_seconds)
1766
- translate_profile = translator.last_profile
1767
- chunk_count = int(translate_profile.get("chunks") or len(rows))
1768
  fallback_chunks = int(translate_profile.get("paragraph_fallback_chunks") or 0)
1769
  fallback_lines = int(translate_profile.get("paragraph_fallback_lines") or 0)
1770
  measured_chunks = max(chunk_count + fallback_chunks, 1)
@@ -1801,10 +1901,22 @@ def _translate_run(
1801
  glossary_note = (
1802
  f" Glossary: {glossary_report.entries} mục, "
1803
  f"{glossary_report.source_hits} hit nguồn; "
1804
- f"{glossary_report.replacements} thay thế, "
1805
  f"{glossary_report.satisfied} đã đúng, "
1806
  f"{glossary_report.unresolved} chưa khớp alias."
1807
  )
 
 
 
 
 
 
 
 
 
 
 
 
1808
  elif glossary_warning:
1809
  glossary_note = (
1810
  f" Glossary lỗi, đã giữ bản trước glossary: {glossary_warning}."
@@ -2148,9 +2260,11 @@ def build_ui() -> gr.Blocks:
2148
  with gr.Accordion("📚 Tên riêng & thuật ngữ (glossary)", open=False,
2149
  elem_id="glossary-accordion"):
2150
  gr.Markdown(
2151
- "Glossary chỉ chuẩn hóa **alias tiếng Việt** khi dòng nguồn chứa đúng "
2152
- "`source_zh`; không tự chèn tên bị model bỏ sót. "
2153
- "`Loại` chỉ để quản **không bắt buộc**. Nhiều alias ngăn bằng `|`.",
 
 
2154
  elem_classes=["honorific-hint"],
2155
  )
2156
  glossary_table = gr.Dataframe(
@@ -2159,7 +2273,7 @@ def build_ui() -> gr.Blocks:
2159
  "Nguồn Trung *",
2160
  "Đích Việt *",
2161
  "Loại (tùy chọn)",
2162
- "Alias Việt (ngăn bằng |)",
2163
  "Bật",
2164
  ],
2165
  datatype=["str", "str", "str", "str", "bool"],
 
47
  import gradio as gr
48
 
49
  from glossary import (
50
+ PLACEHOLDER_SCOPE_DOCUMENT,
51
+ PLACEHOLDER_SCOPE_LINE,
52
  GlossaryValidationError,
53
  apply_glossary_rows,
54
  compile_glossary,
55
  glossary_table_rows,
56
+ protect_glossary_text,
57
  read_glossary_file,
58
+ restore_glossary_rows,
59
  write_glossary_file,
60
  )
61
  import hardware
 
1520
  return (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
1521
 
1522
 
1523
+ def rebuild_paragraph_layout(
1524
  source_text: str, rows: list[tuple[int, str, str]]
1525
  ) -> str:
1526
  """Ghép bản dịch của các dòng (rows) trở lại ĐÚNG bố cục dòng của nguồn.
 
1539
  sentinel = object()
1540
  if next(translated_iter, sentinel) is not sentinel:
1541
  raise RuntimeError("Số row dịch lớn hơn số dòng nguồn không rỗng")
1542
+ return "\n".join(output)
1543
+
1544
+
1545
+ def _result_text_from_rows(
1546
+ source_text: str,
1547
+ rows: list[tuple[int, str, str]],
1548
+ backend: str,
1549
+ ) -> str:
1550
+ if backend == Backend.CT2.value:
1551
+ return rebuild_paragraph_layout(source_text, rows)
1552
+ return "\n".join(translated_vi for _, _, translated_vi in rows)
1553
+
1554
+
1555
+ def _retry_glossary_failed_rows(
1556
+ rows: list[tuple[int, str, str]],
1557
+ failed_indices: tuple[int, ...],
1558
+ *,
1559
+ beam_size: int,
1560
+ ) -> list[tuple[int, str, str]]:
1561
+ """Translate failed placeholder rows again from their original source."""
1562
+ failed = set(failed_indices)
1563
+ source_by_index = {
1564
+ index: source_zh
1565
+ for index, source_zh, _translated_vi in rows
1566
+ if index in failed
1567
+ }
1568
+ missing = failed - set(source_by_index)
1569
+ if missing:
1570
+ raise RuntimeError(
1571
+ "Không tìm thấy dòng nguồn để fallback glossary: "
1572
+ + ", ".join(str(index) for index in sorted(missing))
1573
+ )
1574
+
1575
+ fallback_by_index: dict[int, str] = {}
1576
+ for index in failed_indices:
1577
+ fallback_text: str | None = None
1578
+ for _done, _total, _message, result_rows, result_text in translator.translate_text_iter(
1579
+ source_by_index[index],
1580
+ chunk_mode="paragraph",
1581
+ beam_size=beam_size,
1582
+ ):
1583
+ if result_rows is not None and result_text is not None:
1584
+ fallback_text = result_text
1585
+ if fallback_text is None:
1586
+ raise RuntimeError(f"Fallback glossary không trả kết quả cho dòng {index}.")
1587
+ fallback_by_index[index] = fallback_text.strip()
1588
+
1589
+ return [
1590
+ (
1591
+ index,
1592
+ source_zh,
1593
+ fallback_by_index.get(index, translated_vi),
1594
+ )
1595
+ for index, source_zh, translated_vi in rows
1596
+ ]
1597
+
1598
+
1599
+ def apply_postprocess_rows(
1600
  rows: list[tuple[int, str, str]],
1601
  *,
1602
  honorific_kinship: bool,
 
1738
  raise gr.Error(f"Glossary không hợp lệ: {_exception_message(exc)}") from exc
1739
  honorific_kinship = bool(honorific_kinship)
1740
  honorific_pronouns = bool(honorific_pronouns)
1741
+ honorific_on = honorific_kinship or honorific_pronouns
1742
+ pronoun_harmonizer_v9 = bool(pronoun_harmonizer_v9)
1743
+ placeholder_scope = (
1744
+ PLACEHOLDER_SCOPE_LINE
1745
+ if backend == Backend.CT2.value
1746
+ else PLACEHOLDER_SCOPE_DOCUMENT
1747
+ )
1748
+ glossary_protection = protect_glossary_text(
1749
+ source,
1750
+ glossary_entries,
1751
+ scope=placeholder_scope,
1752
+ )
1753
+ model_source = glossary_protection.text
1754
+
1755
+ if is_model_downloaded(model_key, backend):
1756
+ load_msg = "Đang nạp model..."
1757
  else:
1758
  label = MODELS[model_key].label if model_key in MODELS else model_key
1759
  load_msg = f"Đang tải model {label} từ Hugging Face (lần đầu, vui lòng đợi)..."
 
1763
  resolve_batch_size(auto_batch, manual_batch)
1764
  status = ensure_model(model_key, backend, beam_size)
1765
 
1766
+ protection_note = (
1767
+ f" Đã bảo vệ {glossary_protection.protected_occurrences} tên/thuật ngữ."
1768
+ if glossary_protection.protected_occurrences
1769
+ else ""
1770
+ )
1771
+ set_progress(
1772
+ 2,
1773
+ f"{normalize_msg}{protection_note} Đang chia chunk...",
1774
+ session=session,
1775
+ )
1776
+ yield _progress_stream_update(session=session)
1777
 
1778
  rows: list[tuple[int, str, str]] = []
1779
  full_text = ""
1780
  last_progress_update = 0.0
1781
  translate_start = time.perf_counter()
1782
+ run_id = uuid.uuid4().hex[:8]
1783
+
1784
+ for done, total, message, result_rows, result_text in translator.translate_text_iter(
1785
+ model_source,
1786
+ chunk_mode=chunk_mode,
1787
+ beam_size=int(beam_size),
1788
+ ):
1789
  if result_rows is not None and result_text is not None:
1790
  rows = result_rows
1791
  full_text = result_text
 
1803
  pct = round(done / max(total, 1) * 100, 1)
1804
  detail = f"{message} ({pct}%)"
1805
  set_progress(pct, detail, session=session)
1806
+ last_progress_update = now
1807
+ yield _progress_stream_update(session=session)
1808
+
1809
+ translate_profile = dict(translator.last_profile)
1810
+ glossary_restore_report = None
1811
+ glossary_retry_rows = 0
1812
+ if glossary_protection.protected_occurrences:
1813
+ rows, glossary_restore_report = restore_glossary_rows(
1814
+ rows,
1815
+ glossary_protection,
1816
+ )
1817
+ if glossary_restore_report.failed_indices:
1818
+ glossary_retry_rows = glossary_restore_report.failed_rows
1819
+ set_progress(
1820
+ 99,
1821
+ f"Glossary: dịch lại {glossary_retry_rows} dòng có placeholder lỗi...",
1822
+ session=session,
1823
+ )
1824
+ yield _progress_stream_update(session=session)
1825
+ rows = _retry_glossary_failed_rows(
1826
+ rows,
1827
+ glossary_restore_report.failed_indices,
1828
+ beam_size=int(beam_size),
1829
+ )
1830
+ full_text = _result_text_from_rows(source, rows, backend)
1831
+
1832
+ rows, full_text, postprocess_report = apply_postprocess_rows(
1833
  rows,
1834
  honorific_kinship=honorific_kinship,
1835
  honorific_pronouns=honorific_pronouns,
 
1851
  try:
1852
  rows, glossary_report = apply_glossary_rows(rows, glossary_entries)
1853
  if glossary_report.changed_rows:
1854
+ full_text = _result_text_from_rows(source, rows, backend)
1855
  except Exception as exc:
1856
  rows = before_glossary_rows
1857
  full_text = before_glossary_text
1858
  glossary_warning = _exception_message(exc)
1859
 
1860
+ translate_seconds = time.perf_counter() - translate_start
1861
+
1862
  # KHÔNG auto-tạo file .txt mỗi lần dịch (tránh rác temp) — file chỉ sinh
1863
  # khi user bấm nút "Xuất bản dịch .txt" (export_btn.click). Bản dịch đầy đủ
1864
  # vẫn nằm trong ô + full_text_state để xuất khi cần.
1865
+ download_path = None
1866
+ duration = _format_duration(translate_seconds)
1867
+ chunk_count = int(translate_profile.get("chunks") or len(rows))
 
1868
  fallback_chunks = int(translate_profile.get("paragraph_fallback_chunks") or 0)
1869
  fallback_lines = int(translate_profile.get("paragraph_fallback_lines") or 0)
1870
  measured_chunks = max(chunk_count + fallback_chunks, 1)
 
1901
  glossary_note = (
1902
  f" Glossary: {glossary_report.entries} mục, "
1903
  f"{glossary_report.source_hits} hit nguồn; "
1904
+ f"{glossary_report.replacements} thay thế alias, "
1905
  f"{glossary_report.satisfied} đã đúng, "
1906
  f"{glossary_report.unresolved} chưa khớp alias."
1907
  )
1908
+ if glossary_restore_report is not None:
1909
+ glossary_note += (
1910
+ f" Placeholder: {glossary_restore_report.protected_occurrences} bảo vệ, "
1911
+ f"{glossary_restore_report.restored_occurrences} restore"
1912
+ )
1913
+ if glossary_restore_report.skipped_occurrences:
1914
+ glossary_note += (
1915
+ f", {glossary_restore_report.skipped_occurrences} vượt pool"
1916
+ )
1917
+ if glossary_retry_rows:
1918
+ glossary_note += f", {glossary_retry_rows} dòng retry"
1919
+ glossary_note += "."
1920
  elif glossary_warning:
1921
  glossary_note = (
1922
  f" Glossary lỗi, đã giữ bản trước glossary: {glossary_warning}."
 
2260
  with gr.Accordion("📚 Tên riêng & thuật ngữ (glossary)", open=False,
2261
  elem_id="glossary-accordion"):
2262
  gr.Markdown(
2263
+ "Glossary thay đúng `source_zh` bằng **placeholder đã kiểm định** trước khi "
2264
+ "Marian dịch, rồi khôi phục thành `target_vi`; vậy tên hiếm không phụ "
2265
+ "thuộc vào cách model tự phiên âm. Nếu placeholder bị mất/đổi/lặp, dòng đó "
2266
+ "được dịch lại từ nguồn gốc và mới dùng alias làm fallback. "
2267
+ "`Loại` chỉ để quản lý và **không bắt buộc**.",
2268
  elem_classes=["honorific-hint"],
2269
  )
2270
  glossary_table = gr.Dataframe(
 
2273
  "Nguồn Trung *",
2274
  "Đích Việt *",
2275
  "Loại (tùy chọn)",
2276
+ "Alias fallback (tùy chọn, ngăn bằng |)",
2277
  "Bật",
2278
  ],
2279
  datatype=["str", "str", "str", "str", "bool"],
src/glossary.py CHANGED
@@ -1,8 +1,9 @@
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
@@ -19,6 +20,12 @@ from typing import Any
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):
@@ -44,6 +51,42 @@ class GlossaryReport:
44
  unresolved_terms: tuple[str, ...] = ()
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  def _clean_text(value: Any) -> str:
48
  if value is None:
49
  return ""
@@ -219,6 +262,260 @@ def _source_matches(
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 ""
 
1
+ """User glossary parsing, placeholder protection, restore, and alias fallback.
2
 
3
+ Enabled glossary entries are protected before Marian decoding with a small
4
+ model-tested placeholder pool. Exact placeholders are restored to the
5
+ canonical Vietnamese target before the existing post-processing pipeline.
6
+ Alias canonicalization remains as a compatibility fallback.
7
  """
8
 
9
  from __future__ import annotations
 
20
  GLOSSARY_HEADERS = ("source_zh", "target_vi", "type", "aliases_vi", "enabled")
21
  MAX_GLOSSARY_BYTES = 2_000_000
22
  MAX_GLOSSARY_ROWS = 5_000
23
+ # Frozen from rare_name_eval_v2: every marker reached 127/127 survival with
24
+ # zero overcopy on all eight CT2 models exposed by qt2 (2026-07-27).
25
+ GLOSSARY_PLACEHOLDERS = ("QX7", "KX7", "RX7", "ZX9", "ZQ1", "VX1")
26
+ PLACEHOLDER_SCOPE_LINE = "line"
27
+ PLACEHOLDER_SCOPE_DOCUMENT = "document"
28
+ PLACEHOLDER_SCOPES = {PLACEHOLDER_SCOPE_LINE, PLACEHOLDER_SCOPE_DOCUMENT}
29
 
30
 
31
  class GlossaryValidationError(ValueError):
 
51
  unresolved_terms: tuple[str, ...] = ()
52
 
53
 
54
+ @dataclass(frozen=True, slots=True)
55
+ class GlossaryPlaceholderBinding:
56
+ marker: str
57
+ entry: GlossaryEntry
58
+ occurrences: int
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class GlossaryProtectedRow:
63
+ index: int
64
+ original_source: str
65
+ protected_source: str
66
+ bindings: tuple[GlossaryPlaceholderBinding, ...]
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class GlossaryProtection:
71
+ text: str
72
+ rows: tuple[GlossaryProtectedRow, ...]
73
+ scope: str
74
+ markers: tuple[str, ...]
75
+ source_hits: int
76
+ protected_occurrences: int
77
+ skipped_occurrences: int
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class GlossaryRestoreReport:
82
+ protected_occurrences: int
83
+ restored_occurrences: int
84
+ skipped_occurrences: int
85
+ failed_rows: int
86
+ overcopy_rows: int
87
+ failed_indices: tuple[int, ...] = ()
88
+
89
+
90
  def _clean_text(value: Any) -> str:
91
  if value is None:
92
  return ""
 
262
  return selected
263
 
264
 
265
+ def _source_spans(
266
+ source_text: str,
267
+ entries: Sequence[GlossaryEntry],
268
+ ) -> list[tuple[int, int, GlossaryEntry]]:
269
+ """Return non-overlapping source spans, preferring the longest key."""
270
+ candidates: list[tuple[int, int, int, GlossaryEntry]] = []
271
+ for entry_index, entry in enumerate(entries):
272
+ start = source_text.find(entry.source_zh)
273
+ while start >= 0:
274
+ end = start + len(entry.source_zh)
275
+ candidates.append((start, -len(entry.source_zh), entry_index, entry))
276
+ start = source_text.find(entry.source_zh, start + 1)
277
+ candidates.sort(key=lambda item: item[:3])
278
+
279
+ selected: list[tuple[int, int, GlossaryEntry]] = []
280
+ occupied_until = -1
281
+ for start, negative_length, _entry_index, entry in candidates:
282
+ end = start - negative_length
283
+ if start < occupied_until:
284
+ continue
285
+ selected.append((start, end, entry))
286
+ occupied_until = end
287
+ return selected
288
+
289
+
290
+ def _line_body_and_ending(raw_line: str) -> tuple[str, str]:
291
+ for ending in ("\r\n", "\n", "\r"):
292
+ if raw_line.endswith(ending):
293
+ return raw_line[: -len(ending)], ending
294
+ return raw_line, ""
295
+
296
+
297
+ def _placeholder_pattern(marker: str) -> re.Pattern[str]:
298
+ # ASCII boundaries reject mutated forms such as QX7A while still accepting
299
+ # markers next to Chinese characters in source and normal punctuation in VI.
300
+ return re.compile(
301
+ rf"(?<![A-Za-z0-9]){re.escape(marker)}(?![A-Za-z0-9])",
302
+ re.IGNORECASE,
303
+ )
304
+
305
+
306
+ def protect_glossary_text(
307
+ text: str,
308
+ entries: Sequence[GlossaryEntry],
309
+ *,
310
+ scope: str = PLACEHOLDER_SCOPE_LINE,
311
+ markers: Sequence[str] = GLOSSARY_PLACEHOLDERS,
312
+ ) -> GlossaryProtection:
313
+ """Replace matched source terms with model-tested placeholders.
314
+
315
+ ``line`` scope reuses the finite marker pool on every non-blank source line
316
+ and is used by CT2, whose output rows preserve source-line identity.
317
+ ``document`` scope assigns each marker to one entry for the entire input and
318
+ is the conservative fallback for backends that return arbitrary chunks.
319
+ """
320
+ if scope not in PLACEHOLDER_SCOPES:
321
+ raise ValueError(f"Placeholder scope không hợp lệ: {scope!r}.")
322
+
323
+ marker_pool = tuple(
324
+ dict.fromkeys(_clean_text(marker) for marker in markers if _clean_text(marker))
325
+ )
326
+ collision_values = [text]
327
+ for entry in entries:
328
+ collision_values.extend((entry.target_vi, *entry.aliases_vi))
329
+ marker_pool = tuple(
330
+ marker
331
+ for marker in marker_pool
332
+ if not any(marker.casefold() in value.casefold() for value in collision_values)
333
+ )
334
+
335
+ protected_parts: list[str] = []
336
+ protected_rows: list[GlossaryProtectedRow] = []
337
+ source_hits = 0
338
+ protected_occurrences = 0
339
+ row_index = 0
340
+ document_assignments: dict[GlossaryEntry, str] = {}
341
+
342
+ for raw_line in text.splitlines(keepends=True):
343
+ body, ending = _line_body_and_ending(raw_line)
344
+ if not body.strip():
345
+ protected_parts.append(raw_line)
346
+ continue
347
+
348
+ row_index += 1
349
+ spans = _source_spans(body, entries)
350
+ source_hits += len(spans)
351
+ if not spans or not marker_pool:
352
+ protected_parts.append(raw_line)
353
+ continue
354
+
355
+ ordered_entries: list[GlossaryEntry] = []
356
+ seen_entries: set[GlossaryEntry] = set()
357
+ for _start, _end, entry in spans:
358
+ if entry not in seen_entries:
359
+ ordered_entries.append(entry)
360
+ seen_entries.add(entry)
361
+
362
+ if scope == PLACEHOLDER_SCOPE_DOCUMENT:
363
+ for entry in ordered_entries:
364
+ if entry in document_assignments:
365
+ continue
366
+ if len(document_assignments) >= len(marker_pool):
367
+ break
368
+ document_assignments[entry] = marker_pool[len(document_assignments)]
369
+ assignments = document_assignments
370
+ else:
371
+ assignments = {
372
+ entry: marker
373
+ for entry, marker in zip(ordered_entries, marker_pool, strict=False)
374
+ }
375
+
376
+ counts: dict[GlossaryEntry, int] = {}
377
+ protected_body = body
378
+ for start, end, entry in reversed(spans):
379
+ marker = assignments.get(entry)
380
+ if marker is None:
381
+ continue
382
+ protected_body = protected_body[:start] + marker + protected_body[end:]
383
+ counts[entry] = counts.get(entry, 0) + 1
384
+
385
+ bindings = tuple(
386
+ GlossaryPlaceholderBinding(
387
+ marker=assignments[entry],
388
+ entry=entry,
389
+ occurrences=counts[entry],
390
+ )
391
+ for entry in ordered_entries
392
+ if entry in counts
393
+ )
394
+ protected_occurrences += sum(binding.occurrences for binding in bindings)
395
+ if bindings:
396
+ protected_rows.append(
397
+ GlossaryProtectedRow(
398
+ index=row_index,
399
+ original_source=body.strip(),
400
+ protected_source=protected_body.strip(),
401
+ bindings=bindings,
402
+ )
403
+ )
404
+ protected_parts.append(protected_body + ending)
405
+
406
+ return GlossaryProtection(
407
+ text="".join(protected_parts),
408
+ rows=tuple(protected_rows),
409
+ scope=scope,
410
+ markers=marker_pool,
411
+ source_hits=source_hits,
412
+ protected_occurrences=protected_occurrences,
413
+ skipped_occurrences=source_hits - protected_occurrences,
414
+ )
415
+
416
+
417
+ def restore_glossary_rows(
418
+ rows: Iterable[tuple[int, str, str]],
419
+ protection: GlossaryProtection,
420
+ ) -> tuple[list[tuple[int, str, str]], GlossaryRestoreReport]:
421
+ """Restore exact placeholders and fail a whole row on any count mismatch."""
422
+ protected_by_index = {row.index: row for row in protection.rows}
423
+ document_entries: dict[str, GlossaryEntry] = {}
424
+ if protection.scope == PLACEHOLDER_SCOPE_DOCUMENT:
425
+ for protected_row in protection.rows:
426
+ for binding in protected_row.bindings:
427
+ existing = document_entries.get(binding.marker.casefold())
428
+ if existing is not None and existing != binding.entry:
429
+ raise RuntimeError(
430
+ f"Placeholder {binding.marker!r} ánh xạ tới nhiều glossary entry."
431
+ )
432
+ document_entries[binding.marker.casefold()] = binding.entry
433
+
434
+ output_rows: list[tuple[int, str, str]] = []
435
+ restored_occurrences = 0
436
+ failed_indices: list[int] = []
437
+ overcopy_rows = 0
438
+ seen_line_indices: set[int] = set()
439
+
440
+ for index, source_zh, translated_vi in rows:
441
+ if protection.scope == PLACEHOLDER_SCOPE_LINE:
442
+ protected_row = protected_by_index.get(index)
443
+ if protected_row is None:
444
+ output_rows.append((index, source_zh, translated_vi))
445
+ continue
446
+ seen_line_indices.add(index)
447
+ bindings = protected_row.bindings
448
+ source_matches_row = source_zh.strip() == protected_row.protected_source
449
+ else:
450
+ bindings = tuple(
451
+ GlossaryPlaceholderBinding(
452
+ marker=marker,
453
+ entry=entry,
454
+ occurrences=len(_placeholder_pattern(marker).findall(source_zh)),
455
+ )
456
+ for marker, entry in (
457
+ (marker, document_entries[marker.casefold()])
458
+ for marker in protection.markers
459
+ if marker.casefold() in document_entries
460
+ )
461
+ if _placeholder_pattern(marker).search(source_zh)
462
+ )
463
+ if not bindings:
464
+ output_rows.append((index, source_zh, translated_vi))
465
+ continue
466
+ source_matches_row = True
467
+
468
+ restored_source = source_zh
469
+ expected_counts = {binding.marker.casefold(): binding.occurrences for binding in bindings}
470
+ actual_counts = {
471
+ marker.casefold(): len(_placeholder_pattern(marker).findall(translated_vi))
472
+ for marker in protection.markers
473
+ }
474
+ count_mismatch = any(
475
+ actual_counts.get(marker.casefold(), 0) != expected_counts.get(marker.casefold(), 0)
476
+ for marker in protection.markers
477
+ )
478
+ row_overcopy = any(
479
+ actual_counts.get(marker.casefold(), 0) > expected_counts.get(marker.casefold(), 0)
480
+ for marker in protection.markers
481
+ )
482
+
483
+ for binding in bindings:
484
+ restored_source = _placeholder_pattern(binding.marker).sub(
485
+ lambda _match, value=binding.entry.source_zh: value,
486
+ restored_source,
487
+ )
488
+
489
+ if not source_matches_row or count_mismatch:
490
+ failed_indices.append(index)
491
+ overcopy_rows += int(row_overcopy)
492
+ output_rows.append((index, restored_source, translated_vi))
493
+ continue
494
+
495
+ restored_vi = translated_vi
496
+ for binding in bindings:
497
+ restored_vi = _placeholder_pattern(binding.marker).sub(
498
+ lambda _match, value=binding.entry.target_vi: value,
499
+ restored_vi,
500
+ )
501
+ restored_occurrences += binding.occurrences
502
+ output_rows.append((index, restored_source, restored_vi))
503
+
504
+ if protection.scope == PLACEHOLDER_SCOPE_LINE:
505
+ failed_indices.extend(sorted(set(protected_by_index) - seen_line_indices))
506
+
507
+ unique_failed_indices = tuple(dict.fromkeys(failed_indices))
508
+ report = GlossaryRestoreReport(
509
+ protected_occurrences=protection.protected_occurrences,
510
+ restored_occurrences=restored_occurrences,
511
+ skipped_occurrences=protection.skipped_occurrences,
512
+ failed_rows=len(unique_failed_indices),
513
+ overcopy_rows=overcopy_rows,
514
+ failed_indices=unique_failed_indices,
515
+ )
516
+ return output_rows, report
517
+
518
+
519
  def _literal_pattern(value: str) -> re.Pattern[str]:
520
  left = r"(?<!\w)" if value[0].isalnum() or value[0] == "_" else ""
521
  right = r"(?!\w)" if value[-1].isalnum() or value[-1] == "_" else ""