Spaces:
Running
Running
sync honorific_normalize.py (Codex harden honorific+V9 guards)
Browse files- src/honorific_normalize.py +46 -7
src/honorific_normalize.py
CHANGED
|
@@ -22,10 +22,14 @@ import re
|
|
| 22 |
import unicodedata
|
| 23 |
from collections import Counter
|
| 24 |
|
|
|
|
|
|
|
| 25 |
HONORIFIC_OFF = "off"
|
| 26 |
HONORIFIC_SAFE = "safe"
|
| 27 |
HONORIFIC_STRICT = "xianxia_strict"
|
| 28 |
HONORIFIC_MODES = {HONORIFIC_OFF, HONORIFIC_SAFE, HONORIFIC_STRICT}
|
|
|
|
|
|
|
| 29 |
|
| 30 |
# ---------------------------------------------------------------------------
|
| 31 |
# Bảng tra: ZH-term → {hv (đích Hán-Việt), drift (biến thể VI cần thay), tier}
|
|
@@ -106,6 +110,13 @@ _TARGET_LONG_PHRASES = tuple(
|
|
| 106 |
reverse=True,
|
| 107 |
)
|
| 108 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
_GENERIC_KINSHIP_SINGLE_DRIFTS = {"anh", "chị", "em"}
|
| 110 |
|
| 111 |
WUXIA_SIGNALS = [
|
|
@@ -166,7 +177,7 @@ def _match_case(new: str, old: str) -> str:
|
|
| 166 |
|
| 167 |
|
| 168 |
def _target_spans(text: str, phrase: str) -> list[tuple[int, int]]:
|
| 169 |
-
pat =
|
| 170 |
return [(m.start(), m.end()) for m in re.finditer(pat, text, flags=re.IGNORECASE)]
|
| 171 |
|
| 172 |
|
|
@@ -177,7 +188,7 @@ def _inside_any_span(start: int, end: int, spans: list[tuple[int, int]]) -> bool
|
|
| 177 |
def _protected_target_spans(vi: str, current_variant: str) -> list[tuple[int, int]]:
|
| 178 |
"""Các phrase dài không được để drift một từ ăn vào giữa."""
|
| 179 |
spans: list[tuple[int, int]] = []
|
| 180 |
-
for phrase in
|
| 181 |
if phrase == current_variant:
|
| 182 |
continue
|
| 183 |
if f" {current_variant.casefold()}" not in f" {phrase.casefold()}":
|
|
@@ -190,11 +201,34 @@ def _has_pronoun_competition(variant: str, source_mentions: list[str]) -> bool:
|
|
| 190 |
variant = variant.casefold()
|
| 191 |
if variant == "anh":
|
| 192 |
return "他" in source_mentions
|
|
|
|
|
|
|
| 193 |
if variant == "em":
|
| 194 |
return "你" in source_mentions
|
| 195 |
return False
|
| 196 |
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
def _replace_one_drift(
|
| 199 |
vi: str,
|
| 200 |
variants: list[str],
|
|
@@ -202,7 +236,7 @@ def _replace_one_drift(
|
|
| 202 |
*,
|
| 203 |
tier: str,
|
| 204 |
source_mentions: list[str],
|
| 205 |
-
) -> str:
|
| 206 |
"""Thay một mention theo thứ tự nguồn.
|
| 207 |
|
| 208 |
Nếu chỉ còn drift một từ mơ hồ (`anh/chị/em`) trong dòng có đại từ nguồn
|
|
@@ -213,7 +247,7 @@ def _replace_one_drift(
|
|
| 213 |
if tier.startswith("kinship") and v.casefold() in _GENERIC_KINSHIP_SINGLE_DRIFTS:
|
| 214 |
if _has_pronoun_competition(v, source_mentions):
|
| 215 |
continue
|
| 216 |
-
pat =
|
| 217 |
protected_spans = _protected_target_spans(vi, v)
|
| 218 |
|
| 219 |
def repl(m):
|
|
@@ -226,7 +260,7 @@ def _replace_one_drift(
|
|
| 226 |
vi = re.sub(pat, repl, vi, flags=re.IGNORECASE)
|
| 227 |
if done:
|
| 228 |
break
|
| 229 |
-
return vi
|
| 230 |
|
| 231 |
|
| 232 |
def _skip(tier: str, classical: bool, apply_kinship: bool, apply_pronouns: bool,
|
|
@@ -281,13 +315,17 @@ def normalize_honorifics(zh: str, vi: str, mode: str | None = None,
|
|
| 281 |
|
| 282 |
if (not apply_kinship and not apply_pronouns) or not vi or not zh:
|
| 283 |
return vi
|
|
|
|
| 284 |
vi = unicodedata.normalize("NFC", vi)
|
| 285 |
-
zh = unicodedata.normalize("NFC", zh)
|
| 286 |
mentions = longest_match_mentions(zh)
|
| 287 |
if not mentions:
|
| 288 |
return vi
|
| 289 |
classical = bool(classical_context) if classical_context is not None else is_classical(zh)
|
|
|
|
| 290 |
for term in mentions:
|
|
|
|
|
|
|
| 291 |
entry = HONORIFIC_MAP[term]
|
| 292 |
hv = entry.get("hv")
|
| 293 |
if hv is None:
|
|
@@ -298,13 +336,14 @@ def normalize_honorifics(zh: str, vi: str, mode: str | None = None,
|
|
| 298 |
drift = entry.get("drift", [])
|
| 299 |
if not drift:
|
| 300 |
continue
|
| 301 |
-
vi = _replace_one_drift(
|
| 302 |
vi,
|
| 303 |
drift,
|
| 304 |
hv,
|
| 305 |
tier=entry["tier"],
|
| 306 |
source_mentions=mentions,
|
| 307 |
)
|
|
|
|
| 308 |
return vi
|
| 309 |
|
| 310 |
|
|
|
|
| 22 |
import unicodedata
|
| 23 |
from collections import Counter
|
| 24 |
|
| 25 |
+
from text_preprocess import NORMALIZE_T2S, normalize_chinese_text
|
| 26 |
+
|
| 27 |
HONORIFIC_OFF = "off"
|
| 28 |
HONORIFIC_SAFE = "safe"
|
| 29 |
HONORIFIC_STRICT = "xianxia_strict"
|
| 30 |
HONORIFIC_MODES = {HONORIFIC_OFF, HONORIFIC_SAFE, HONORIFIC_STRICT}
|
| 31 |
+
KINSHIP_MODES = {"always", "classical_only"}
|
| 32 |
+
_BOUNDARY_CHARS = r"\wÀ-ỹ"
|
| 33 |
|
| 34 |
# ---------------------------------------------------------------------------
|
| 35 |
# Bảng tra: ZH-term → {hv (đích Hán-Việt), drift (biến thể VI cần thay), tier}
|
|
|
|
| 110 |
reverse=True,
|
| 111 |
)
|
| 112 |
)
|
| 113 |
+
_RECOGNIZE_ONLY_TARGET_PHRASES = (
|
| 114 |
+
"anh ấy", "anh ta", "chị ấy", "chị ta", "em ấy", "em ta",
|
| 115 |
+
"các anh", "các chị", "các em",
|
| 116 |
+
)
|
| 117 |
+
_PROTECTED_TARGET_PHRASES = tuple(
|
| 118 |
+
sorted(set(_TARGET_LONG_PHRASES) | set(_RECOGNIZE_ONLY_TARGET_PHRASES), key=len, reverse=True)
|
| 119 |
+
)
|
| 120 |
_GENERIC_KINSHIP_SINGLE_DRIFTS = {"anh", "chị", "em"}
|
| 121 |
|
| 122 |
WUXIA_SIGNALS = [
|
|
|
|
| 177 |
|
| 178 |
|
| 179 |
def _target_spans(text: str, phrase: str) -> list[tuple[int, int]]:
|
| 180 |
+
pat = rf"(?<![{_BOUNDARY_CHARS}])" + re.escape(phrase) + rf"(?![{_BOUNDARY_CHARS}])"
|
| 181 |
return [(m.start(), m.end()) for m in re.finditer(pat, text, flags=re.IGNORECASE)]
|
| 182 |
|
| 183 |
|
|
|
|
| 188 |
def _protected_target_spans(vi: str, current_variant: str) -> list[tuple[int, int]]:
|
| 189 |
"""Các phrase dài không được để drift một từ ăn vào giữa."""
|
| 190 |
spans: list[tuple[int, int]] = []
|
| 191 |
+
for phrase in _PROTECTED_TARGET_PHRASES:
|
| 192 |
if phrase == current_variant:
|
| 193 |
continue
|
| 194 |
if f" {current_variant.casefold()}" not in f" {phrase.casefold()}":
|
|
|
|
| 201 |
variant = variant.casefold()
|
| 202 |
if variant == "anh":
|
| 203 |
return "他" in source_mentions
|
| 204 |
+
if variant == "chị":
|
| 205 |
+
return "她" in source_mentions
|
| 206 |
if variant == "em":
|
| 207 |
return "你" in source_mentions
|
| 208 |
return False
|
| 209 |
|
| 210 |
|
| 211 |
+
def _remaining_rewrite_budgets(source_mentions: list[str], vi: str) -> Counter[str]:
|
| 212 |
+
"""Số mention nguồn còn được rewrite sau khi target đã có canonical.
|
| 213 |
+
|
| 214 |
+
Nếu `哥哥` đã được dịch thành `ca ca`, mention đó đã được tiêu thụ và không
|
| 215 |
+
được tiếp tục lấy một `anh` khác trong cùng câu để đổi thêm.
|
| 216 |
+
"""
|
| 217 |
+
budgets: Counter[str] = Counter()
|
| 218 |
+
for term, source_count in Counter(source_mentions).items():
|
| 219 |
+
entry = HONORIFIC_MAP.get(term) or {}
|
| 220 |
+
hv = entry.get("hv")
|
| 221 |
+
if not hv:
|
| 222 |
+
continue
|
| 223 |
+
budgets[term] = max(0, source_count - len(_target_spans(vi, hv)))
|
| 224 |
+
return budgets
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _normalize_kinship_mode(kinship_mode: str | None) -> str:
|
| 228 |
+
mode = (kinship_mode or "always").strip().lower()
|
| 229 |
+
return mode if mode in KINSHIP_MODES else "classical_only"
|
| 230 |
+
|
| 231 |
+
|
| 232 |
def _replace_one_drift(
|
| 233 |
vi: str,
|
| 234 |
variants: list[str],
|
|
|
|
| 236 |
*,
|
| 237 |
tier: str,
|
| 238 |
source_mentions: list[str],
|
| 239 |
+
) -> tuple[str, int]:
|
| 240 |
"""Thay một mention theo thứ tự nguồn.
|
| 241 |
|
| 242 |
Nếu chỉ còn drift một từ mơ hồ (`anh/chị/em`) trong dòng có đại từ nguồn
|
|
|
|
| 247 |
if tier.startswith("kinship") and v.casefold() in _GENERIC_KINSHIP_SINGLE_DRIFTS:
|
| 248 |
if _has_pronoun_competition(v, source_mentions):
|
| 249 |
continue
|
| 250 |
+
pat = rf"(?<![{_BOUNDARY_CHARS}])(" + re.escape(v) + rf")(?![{_BOUNDARY_CHARS}])"
|
| 251 |
protected_spans = _protected_target_spans(vi, v)
|
| 252 |
|
| 253 |
def repl(m):
|
|
|
|
| 260 |
vi = re.sub(pat, repl, vi, flags=re.IGNORECASE)
|
| 261 |
if done:
|
| 262 |
break
|
| 263 |
+
return vi, done
|
| 264 |
|
| 265 |
|
| 266 |
def _skip(tier: str, classical: bool, apply_kinship: bool, apply_pronouns: bool,
|
|
|
|
| 315 |
|
| 316 |
if (not apply_kinship and not apply_pronouns) or not vi or not zh:
|
| 317 |
return vi
|
| 318 |
+
kinship_mode = _normalize_kinship_mode(kinship_mode)
|
| 319 |
vi = unicodedata.normalize("NFC", vi)
|
| 320 |
+
zh = unicodedata.normalize("NFC", normalize_chinese_text(zh, NORMALIZE_T2S))
|
| 321 |
mentions = longest_match_mentions(zh)
|
| 322 |
if not mentions:
|
| 323 |
return vi
|
| 324 |
classical = bool(classical_context) if classical_context is not None else is_classical(zh)
|
| 325 |
+
rewrite_budgets = _remaining_rewrite_budgets(mentions, vi)
|
| 326 |
for term in mentions:
|
| 327 |
+
if rewrite_budgets[term] <= 0:
|
| 328 |
+
continue
|
| 329 |
entry = HONORIFIC_MAP[term]
|
| 330 |
hv = entry.get("hv")
|
| 331 |
if hv is None:
|
|
|
|
| 336 |
drift = entry.get("drift", [])
|
| 337 |
if not drift:
|
| 338 |
continue
|
| 339 |
+
vi, changed = _replace_one_drift(
|
| 340 |
vi,
|
| 341 |
drift,
|
| 342 |
hv,
|
| 343 |
tier=entry["tier"],
|
| 344 |
source_mentions=mentions,
|
| 345 |
)
|
| 346 |
+
rewrite_budgets[term] -= changed
|
| 347 |
return vi
|
| 348 |
|
| 349 |
|