Spaces:
Running
Running
feat: add glossary placeholder restore
Browse files- hachimimt-local.zip +2 -2
- src/app.py +149 -35
- 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:
|
| 3 |
-
size
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1687 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1698 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 1732 |
-
|
| 1733 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
| 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
|
| 2152 |
-
"`
|
| 2153 |
-
"
|
|
|
|
|
|
|
| 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
|
| 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ì 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
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 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 ""
|