Spaces:
Running
Running
| """Chuẩn hóa xưng hô Hán-Việt cho bản dịch (hậu kỳ, neo nguồn ZH). | |
| Người đọc muốn xưng hô nhất quán Hán-Việt (tỷ/muội/ca ca/sư huynh/hắn/nàng/ta), | |
| không nhảy "chị em" ↔ "tỷ muội" trong một chương. | |
| Nguyên lý: CHỈ đổi khi NGUỒN tiếng Trung có từ xưng hô tường minh (neo nguồn ZH). | |
| Chỗ nguồn không có từ → không đụng. KHÔNG suy luận quan hệ. Thuần hậu kỳ, không | |
| cần model. Áp per-row (mỗi câu/đoạn có cặp zh-vi riêng → neo chính xác). | |
| API (khớp phong cách text_preprocess.py): | |
| normalize_honorifics(zh, vi, mode="off", kinship_mode="always") -> str | |
| honorific_message(mode) -> str | |
| HONORIFIC_MODES = {off, safe, xianxia_strict} | |
| Port từ MT-zhvi-medium-train/scripts/pronoun/honorific_normalizer.py (qua TDD, | |
| 68 test). Đại từ chỉ bật ở mode xianxia_strict + gate cổ trang (tránh phá văn | |
| hiện đại); thân tộc đơn (哥/姐 1 ký tự) TẮT vì hay trùng tên riêng/loanword. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| import unicodedata | |
| from collections import Counter | |
| from text_preprocess import NORMALIZE_T2S, normalize_chinese_text | |
| HONORIFIC_OFF = "off" | |
| HONORIFIC_SAFE = "safe" | |
| HONORIFIC_STRICT = "xianxia_strict" | |
| HONORIFIC_MODES = {HONORIFIC_OFF, HONORIFIC_SAFE, HONORIFIC_STRICT} | |
| KINSHIP_MODES = {"always", "classical_only"} | |
| _BOUNDARY_CHARS = r"\wÀ-ỹ" | |
| # --------------------------------------------------------------------------- | |
| # Bảng tra: ZH-term → {hv (đích Hán-Việt), drift (biến thể VI cần thay), tier} | |
| # tier "kinship" = thân tộc/cặp/đồng môn/title (áp ở safe) | |
| # tier "kinship_single" = thân tộc 1 ký tự (TẮT — hay trùng proper-noun) | |
| # tier "pronoun" = đại từ (chỉ xianxia_strict + gate cổ trang) | |
| # hv=None = cụm KHÔNG phải xưng hô (chiếm span longest-match, chặn term ngắn lọt). | |
| # --------------------------------------------------------------------------- | |
| _RAW = { | |
| # cụm không phải xưng hô (blacklist) | |
| "哥们": {"hv": None}, "哥们儿": {"hv": None}, "哥儿们": {"hv": None}, | |
| "大哥大": {"hv": None}, "弟媳": {"hv": None}, "弟妹": {"hv": None}, | |
| "小姐": {"hv": None}, "大嫂": {"hv": None}, "嫂子": {"hv": None}, "嫂": {"hv": None}, | |
| # ngoài scope đại từ số nhiều / compound sở hữu: chặn term số ít lọt vào | |
| "我们": {"hv": None}, "咱们": {"hv": None}, "你们": {"hv": None}, | |
| "他们": {"hv": None}, "她们": {"hv": None}, | |
| "我校": {"hv": None}, "我司": {"hv": None}, "我国": {"hv": None}, | |
| "我方": {"hv": None}, "我院": {"hv": None}, | |
| # đồng môn tu tiên | |
| "师兄": {"hv": "sư huynh", "drift": ["sư ca", "sư anh"], "tier": "kinship"}, | |
| "师弟": {"hv": "sư đệ", "drift": ["sư em"], "tier": "kinship"}, | |
| "师姐": {"hv": "sư tỷ", "drift": ["sư chị"], "tier": "kinship"}, | |
| "师妹": {"hv": "sư muội", "drift": ["sư em"], "tier": "kinship"}, | |
| "师叔": {"hv": "sư thúc", "drift": [], "tier": "kinship"}, | |
| "师伯": {"hv": "sư bá", "drift": [], "tier": "kinship"}, | |
| "师祖": {"hv": "sư tổ", "drift": [], "tier": "kinship"}, | |
| "徒儿": {"hv": "đồ nhi", "drift": [], "tier": "kinship"}, | |
| # cặp collective | |
| "姐妹": {"hv": "tỷ muội", "drift": ["chị em"], "tier": "kinship"}, | |
| "兄弟": {"hv": "huynh đệ", "drift": ["anh em"], "tier": "kinship"}, | |
| "兄妹": {"hv": "huynh muội", "drift": ["anh em"], "tier": "kinship"}, | |
| "姐弟": {"hv": "tỷ đệ", "drift": ["chị em"], "tier": "kinship"}, | |
| "师徒": {"hv": "sư đồ", "drift": [], "tier": "kinship"}, | |
| # thân tộc đôi | |
| "姐姐": {"hv": "tỷ tỷ", "drift": ["chị gái", "chị"], "tier": "kinship"}, | |
| "妹妹": {"hv": "muội muội", "drift": ["em gái"], "tier": "kinship"}, | |
| "哥哥": {"hv": "ca ca", "drift": ["anh trai", "anh"], "tier": "kinship"}, | |
| "弟弟": {"hv": "đệ đệ", "drift": ["em trai"], "tier": "kinship"}, | |
| # titles | |
| "大哥": {"hv": "đại ca", "drift": ["anh cả", "anh lớn", "anh hai"], "tier": "kinship"}, | |
| "公子": {"hv": "công tử", "drift": [], "tier": "kinship"}, | |
| "姑娘": {"hv": "cô nương", "drift": [], "tier": "kinship"}, | |
| "师尊": {"hv": "sư tôn", "drift": [], "tier": "kinship"}, | |
| "师父": {"hv": "sư phụ", "drift": [], "tier": "kinship"}, | |
| "前辈": {"hv": "tiền bối", "drift": [], "tier": "kinship"}, | |
| "晚辈": {"hv": "vãn bối", "drift": [], "tier": "kinship"}, | |
| "本座": {"hv": "bản tọa", "drift": [], "tier": "kinship"}, | |
| # thân tộc ĐƠN (TẮT mặc định — 1 ký tự hay trùng proper-noun 哥伦比亚/空姐) | |
| "姐": {"hv": "tỷ", "drift": ["chị"], "tier": "kinship_single"}, | |
| "妹": {"hv": "muội", "drift": [], "tier": "kinship_single"}, | |
| "哥": {"hv": "ca", "drift": ["anh"], "tier": "kinship_single"}, | |
| "弟": {"hv": "đệ", "drift": [], "tier": "kinship_single"}, | |
| # đại từ (pronoun) — drift đã loại từ trùng tên riêng (anh/chị) + "mình" | |
| "你": {"hv": "ngươi", "drift": ["cậu", "bạn", "mày"], "tier": "pronoun"}, | |
| "您": {"hv": "ngài", "drift": ["ông", "bác"], "tier": "pronoun"}, | |
| "他": {"hv": "hắn", "drift": ["anh ấy", "anh ta", "cậu ấy", "cậu ta", "gã"], "tier": "pronoun"}, | |
| "她": {"hv": "nàng", "drift": ["cô ấy", "cô ta", "ả"], "tier": "pronoun"}, | |
| "我": {"hv": "ta", "drift": ["tôi", "tớ"], "tier": "pronoun"}, | |
| } | |
| HONORIFIC_MAP: dict[str, dict] = {} | |
| for _k, _v in _RAW.items(): | |
| _e = dict(_v) | |
| if _e.get("drift"): | |
| _e["drift"] = sorted(_e["drift"], key=len, reverse=True) # phrase dài trước | |
| HONORIFIC_MAP[_k] = _e | |
| _TERMS_BY_LEN = sorted(HONORIFIC_MAP.keys(), key=len, reverse=True) | |
| _TARGET_LONG_PHRASES = tuple( | |
| sorted( | |
| { | |
| drift | |
| for entry in HONORIFIC_MAP.values() | |
| for drift in entry.get("drift", []) | |
| if " " in drift | |
| }, | |
| key=len, | |
| reverse=True, | |
| ) | |
| ) | |
| _RECOGNIZE_ONLY_TARGET_PHRASES = ( | |
| "anh ấy", "anh ta", "chị ấy", "chị ta", "em ấy", "em ta", | |
| "các anh", "các chị", "các em", | |
| ) | |
| _PROTECTED_TARGET_PHRASES = tuple( | |
| sorted(set(_TARGET_LONG_PHRASES) | set(_RECOGNIZE_ONLY_TARGET_PHRASES), key=len, reverse=True) | |
| ) | |
| _GENERIC_KINSHIP_SINGLE_DRIFTS = {"anh", "chị", "em"} | |
| WUXIA_SIGNALS = [ | |
| "修士", "修真", "修仙", "元婴", "金丹", "筑基", "真君", "法宝", "丹药", "灵气", | |
| "仙人", "仙子", "剑修", "渡劫", "结丹", "化神", "真人", "道君", "宗门", "灵根", | |
| "本座", "贫道", "道友", "天劫", "神识", "真元", "灵石", "符箓", "阵法", "飞剑", | |
| ] | |
| MODERN_SIGNALS = [ | |
| "公司", "大学", "电话", "手机", "电脑", "网络", "汽车", "老板", "经理", "项目", | |
| "咖啡", "地铁", "飞机", "酒店", "警察", "医院", "护士", "短信", "微信", "视频", | |
| "直播", "电视", "银行", "信用卡", "互联网", "程序", "软件", "总裁", "董事长", | |
| ] | |
| def genre_score(zh: str) -> tuple[int, int]: | |
| c = sum(1 for s in WUXIA_SIGNALS if s in zh) | |
| m = sum(1 for s in MODERN_SIGNALS if s in zh) | |
| return c, m | |
| def is_classical(zh: str) -> bool: | |
| """Có signal cổ trang VÀ không lẫn hiện đại → coi là cổ trang (bảo thủ). | |
| Nếu có signal hiện đại thì cần cổ trang áp đảo (chặn 'hiện đại tu tiên').""" | |
| c, m = genre_score(zh) | |
| if m == 0: | |
| return c >= 1 | |
| return c >= 2 and c > m | |
| def longest_match_mentions(zh: str) -> list[str]: | |
| """Quét nguồn trái→phải, tại mỗi vị trí chọn term dài nhất. | |
| Cách này giữ đúng thứ tự mention trong nguồn và vẫn chặn term ngắn lọt vào | |
| span term dài (哥哥 chặn 哥; 我们 chặn 我). | |
| """ | |
| mentions: list[str] = [] | |
| i = 0 | |
| while i < len(zh): | |
| matched = "" | |
| for term in _TERMS_BY_LEN: | |
| if zh.startswith(term, i): | |
| matched = term | |
| break | |
| if matched: | |
| mentions.append(matched) | |
| i += len(matched) | |
| else: | |
| i += 1 | |
| return mentions | |
| def longest_match_terms(zh: str) -> dict[str, int]: | |
| return dict(Counter(longest_match_mentions(zh))) | |
| def _match_case(new: str, old: str) -> str: | |
| return new[:1].upper() + new[1:] if old[:1].isupper() else new | |
| def _target_spans(text: str, phrase: str) -> list[tuple[int, int]]: | |
| pat = rf"(?<![{_BOUNDARY_CHARS}])" + re.escape(phrase) + rf"(?![{_BOUNDARY_CHARS}])" | |
| return [(m.start(), m.end()) for m in re.finditer(pat, text, flags=re.IGNORECASE)] | |
| def _inside_any_span(start: int, end: int, spans: list[tuple[int, int]]) -> bool: | |
| return any(span_start <= start and end <= span_end for span_start, span_end in spans) | |
| def _protected_target_spans(vi: str, current_variant: str) -> list[tuple[int, int]]: | |
| """Các phrase dài không được để drift một từ ăn vào giữa.""" | |
| spans: list[tuple[int, int]] = [] | |
| for phrase in _PROTECTED_TARGET_PHRASES: | |
| if phrase == current_variant: | |
| continue | |
| if f" {current_variant.casefold()}" not in f" {phrase.casefold()}": | |
| continue | |
| spans.extend(_target_spans(vi, phrase)) | |
| return spans | |
| def _has_pronoun_competition(variant: str, source_mentions: list[str]) -> bool: | |
| variant = variant.casefold() | |
| if variant == "anh": | |
| return "他" in source_mentions | |
| if variant == "chị": | |
| return "她" in source_mentions | |
| if variant == "em": | |
| return "你" in source_mentions | |
| return False | |
| def _remaining_rewrite_budgets(source_mentions: list[str], vi: str) -> Counter[str]: | |
| """Số mention nguồn còn được rewrite sau khi target đã có canonical. | |
| Nếu `哥哥` đã được dịch thành `ca ca`, mention đó đã được tiêu thụ và không | |
| được tiếp tục lấy một `anh` khác trong cùng câu để đổi thêm. | |
| """ | |
| budgets: Counter[str] = Counter() | |
| for term, source_count in Counter(source_mentions).items(): | |
| entry = HONORIFIC_MAP.get(term) or {} | |
| hv = entry.get("hv") | |
| if not hv: | |
| continue | |
| budgets[term] = max(0, source_count - len(_target_spans(vi, hv))) | |
| return budgets | |
| def _normalize_kinship_mode(kinship_mode: str | None) -> str: | |
| mode = (kinship_mode or "always").strip().lower() | |
| return mode if mode in KINSHIP_MODES else "classical_only" | |
| def _replace_one_drift( | |
| vi: str, | |
| variants: list[str], | |
| target: str, | |
| *, | |
| tier: str, | |
| source_mentions: list[str], | |
| ) -> tuple[str, int]: | |
| """Thay một mention theo thứ tự nguồn. | |
| Nếu chỉ còn drift một từ mơ hồ (`anh/chị/em`) trong dòng có đại từ nguồn | |
| cạnh tranh, bỏ qua để tránh kiểu `Anh ấy` → `Ca ca ấy`. | |
| """ | |
| done = 0 | |
| for v in variants: | |
| if tier.startswith("kinship") and v.casefold() in _GENERIC_KINSHIP_SINGLE_DRIFTS: | |
| if _has_pronoun_competition(v, source_mentions): | |
| continue | |
| pat = rf"(?<![{_BOUNDARY_CHARS}])(" + re.escape(v) + rf")(?![{_BOUNDARY_CHARS}])" | |
| protected_spans = _protected_target_spans(vi, v) | |
| def repl(m): | |
| nonlocal done | |
| if done >= 1 or _inside_any_span(m.start(), m.end(), protected_spans): | |
| return m.group(0) | |
| done += 1 | |
| return _match_case(target, m.group(0)) | |
| vi = re.sub(pat, repl, vi, flags=re.IGNORECASE) | |
| if done: | |
| break | |
| return vi, done | |
| def _skip(tier: str, classical: bool, apply_kinship: bool, apply_pronouns: bool, | |
| kinship_mode: str, enable_single: bool) -> bool: | |
| """True = bỏ qua term. 2 cờ độc lập: apply_kinship (thân tộc/cặp/title) + | |
| apply_pronouns (đại từ). Đại từ luôn còn gate cổ trang.""" | |
| if tier == "pronoun": | |
| return (not apply_pronouns) or (not classical) | |
| if tier == "kinship_single": | |
| if not apply_kinship or not enable_single: | |
| return True | |
| return kinship_mode == "classical_only" and not classical | |
| if tier == "kinship": | |
| if not apply_kinship: | |
| return True | |
| return kinship_mode == "classical_only" and not classical | |
| return False | |
| def honorific_mode(mode: str | None) -> str: | |
| mode = (mode or HONORIFIC_OFF).strip().lower() | |
| return mode if mode in HONORIFIC_MODES else HONORIFIC_OFF | |
| def _mode_to_flags(mode: str) -> tuple[bool, bool]: | |
| """map mode lũy tiến (tương thích ngược) → (apply_kinship, apply_pronouns).""" | |
| mode = honorific_mode(mode) | |
| if mode == HONORIFIC_SAFE: | |
| return True, False | |
| if mode == HONORIFIC_STRICT: | |
| return True, True | |
| return False, False # off | |
| def normalize_honorifics(zh: str, vi: str, mode: str | None = None, | |
| *, apply_kinship: bool | None = None, | |
| apply_pronouns: bool | None = None, | |
| kinship_mode: str = "always", | |
| enable_single: bool = False, | |
| classical_context: bool | None = None) -> str: | |
| """Chuẩn hóa xưng hô VI theo neo nguồn ZH. | |
| 2 cờ ĐỘC LẬP (ưu tiên nếu truyền): apply_kinship (thân tộc tỷ/muội/ca ca...) + | |
| apply_pronouns (đại từ ngươi/hắn/nàng/ta, vẫn gate cổ trang). Cho phép mọi tổ hợp, | |
| gồm 'chỉ đại từ'. `mode` (off/safe/xianxia_strict) = tương thích ngược, map sang | |
| 2 cờ khi 2 cờ không được truyền.""" | |
| if apply_kinship is None and apply_pronouns is None: | |
| apply_kinship, apply_pronouns = _mode_to_flags(mode) | |
| else: | |
| apply_kinship = bool(apply_kinship) | |
| apply_pronouns = bool(apply_pronouns) | |
| if (not apply_kinship and not apply_pronouns) or not vi or not zh: | |
| return vi | |
| kinship_mode = _normalize_kinship_mode(kinship_mode) | |
| vi = unicodedata.normalize("NFC", vi) | |
| zh = unicodedata.normalize("NFC", normalize_chinese_text(zh, NORMALIZE_T2S)) | |
| mentions = longest_match_mentions(zh) | |
| if not mentions: | |
| return vi | |
| classical = bool(classical_context) if classical_context is not None else is_classical(zh) | |
| rewrite_budgets = _remaining_rewrite_budgets(mentions, vi) | |
| for term in mentions: | |
| if rewrite_budgets[term] <= 0: | |
| continue | |
| entry = HONORIFIC_MAP[term] | |
| hv = entry.get("hv") | |
| if hv is None: | |
| continue | |
| if _skip(entry["tier"], classical, apply_kinship, apply_pronouns, | |
| kinship_mode, enable_single): | |
| continue | |
| drift = entry.get("drift", []) | |
| if not drift: | |
| continue | |
| vi, changed = _replace_one_drift( | |
| vi, | |
| drift, | |
| hv, | |
| tier=entry["tier"], | |
| source_mentions=mentions, | |
| ) | |
| rewrite_budgets[term] -= changed | |
| return vi | |
| # alias để test suite port (dùng tên `normalize`) chạy được | |
| normalize = normalize_honorifics | |
| def honorific_message(mode: str | None) -> str: | |
| mode = honorific_mode(mode) | |
| if mode == HONORIFIC_OFF: | |
| return "Giữ nguyên xưng hô theo bản dịch." | |
| if mode == HONORIFIC_SAFE: | |
| return "Đã chuẩn hóa xưng hô thân tộc sang Hán-Việt (tỷ/muội/ca ca...)." | |
| return "Đã chuẩn hóa xưng hô Hán-Việt gồm cả đại từ (ngươi/hắn/nàng/ta)." | |